Compare commits

...

19 Commits

Author SHA1 Message Date
stefano 251148c3ff Perform code optimization and document cleanup (#1)
check / check (ubuntu-latest) (push) Successful in 2m5s
check / check (macos-latest) (push) Has been cancelled
check-online / check-online (ubuntu-latest) (push) Successful in 1m53s
## Summary
- add repository-wide quality tooling and verification scaffolding, including CI workflows, pnpm workspace setup, ESLint/Prettier/markdown checks, and generated-output verification helpers
- reorganize skill sources and generation flow by introducing canonical `_source` variants, generator/manifests, reusable helper abstractions, and shared web-automation/browser utilities
- clean up and expand documentation so the root README flows into docs and skill docs, with clearer development, reviewer, installer, and workflow guidance

## Notable changes
- docs flow and consistency cleanup across `README.md`, `docs/README.md`, and related docs
- new scripts for `check`, docs verification, generated-file verification, shell portability, and safe directory replacement
- refactors in Atlassian and web-automation skill runtimes to reduce duplication and centralize reusable code
- changelog, development documentation, and CI surface updates

## Test Plan
- [ ] `pnpm run check`
- [ ] review generated/manifests and skill sync outputs
- [ ] smoke-check docs flow from `README.md` to `docs/README.md` to skill docs

## Notes
- this branch currently includes tracked `skills/web-automation/shared/node_modules` content that should be reviewed carefully as potentially noisy/accidental committed artifacts

Co-authored-by: Stefano Fiorini <stefano.fiorini@firsthorizon.com>
Reviewed-on: #1
2026-05-04 04:41:34 +00:00
Stefano Fiorini 2deab1c1b4 docs: align skill workflow documentation 2026-04-24 02:44:32 -05:00
Stefano Fiorini 193cd45db8 feat(installer): improve cursor and opencode skill handling 2026-04-24 02:20:06 -05:00
Stefano Fiorini d62899308a feat(installer): support pi package remove and update 2026-04-23 22:55:41 -05:00
Stefano Fiorini 8ea6d08e77 docs: add pi manual install guidance 2026-04-23 21:53:27 -05:00
Stefano Fiorini 3966b77623 chore: package skill manager resources 2026-04-23 21:37:15 -05:00
Stefano Fiorini 494e29f797 docs: add skill manager documentation 2026-04-23 21:27:52 -05:00
Stefano Fiorini f01721a45b feat: add multi-client skill manager 2026-04-23 21:21:31 -05:00
Stefano Fiorini 231a66f2b1 feat: add pi reviewer support to workflow variants 2026-04-23 21:03:45 -05:00
Stefano Fiorini ce4746b769 test: add reviewer support verification 2026-04-23 20:49:09 -05:00
Stefano Fiorini 912aed93a7 feat(pi): support pi reviewer model routing 2026-04-23 19:13:22 -05:00
Stefano Fiorini 9e29c34c62 fix(pi): add installer and runtime path guidance 2026-04-23 18:40:05 -05:00
Stefano Fiorini 3429dac894 fix(pi): package warning-free skill mirror and docs 2026-04-23 17:32:26 -05:00
Stefano Fiorini 0456c51291 docs(pi): implement milestone M6 - validation and surfacing 2026-04-23 16:26:42 -05:00
Stefano Fiorini f2c4d39abd feat(pi): implement milestone M5 - package surface 2026-04-23 16:22:08 -05:00
Stefano Fiorini d7651ad57c docs(pi): implement milestone M4 - extension assessment 2026-04-23 16:17:24 -05:00
Stefano Fiorini 3d868a852c feat(pi): implement milestone M3 - workflow skills 2026-04-23 16:14:59 -05:00
Stefano Fiorini 51372eb420 feat(pi): implement milestone M2 - script-backed skills 2026-04-23 16:04:39 -05:00
Stefano Fiorini 7ba6f90e14 docs(pi): implement milestone M1 - research and shared docs 2026-04-23 15:58:19 -05:00
387 changed files with 48433 additions and 851 deletions
+40
View File
@@ -0,0 +1,40 @@
name: check-online
# Runs full external link checking on a schedule and on manual trigger.
# Kept separate from the main `check` workflow so everyday push/PR CI
# is not slowed down or broken by transient network issues.
on:
schedule:
# Every Monday at 09:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:
jobs:
check-online:
name: check-online (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install shellcheck
run: sudo apt-get update -q && sudo apt-get install -y -q shellcheck
- name: Install ripgrep
run: sudo apt-get install -y -q ripgrep
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run full quality suite with external link checking
run: pnpm run verify:docs:online
+46
View File
@@ -0,0 +1,46 @@
name: check
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
check:
name: check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install shellcheck (Ubuntu)
if: runner.os == 'Linux'
run: sudo apt-get update -q && sudo apt-get install -y -q shellcheck
- name: Install shellcheck (macOS)
if: runner.os == 'macOS'
run: brew install shellcheck
- name: Install ripgrep (Ubuntu)
if: runner.os == 'Linux'
run: sudo apt-get install -y -q ripgrep
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run full quality suite (offline link-checking)
run: pnpm run check
+4
View File
@@ -1,7 +1,11 @@
/ai_plan/
/.pi/
/.worktrees/
/node_modules/
/skills/atlassian/shared/scripts/.env
/skills/atlassian/shared/scripts/node_modules/
/skills/atlassian/*/scripts/.env
/skills/atlassian/*/scripts/node_modules/
/skills/web-automation/*/scripts/node_modules/
/skills/web-automation/shared/node_modules/
/pi-package/skills/*/scripts/node_modules/
+40
View File
@@ -0,0 +1,40 @@
// markdownlint-cli2 configuration — ai-coding-skills (M2)
//
// This file controls which files are linted and which are ignored.
// The markdownlint *rules* are configured in .markdownlint.jsonc (root) and
// skills/.markdownlint.jsonc (agent-facing skill files).
//
// Scope: README.md, docs/, and canonical SKILL.md files.
// Excluded: all node_modules, generated agent-variant directories, pi-package.
{
// Glob patterns for which files to lint
"globs": [
"README.md",
"docs/**/*.md",
// Canonical skill files only — agent variants are excluded below
"skills/atlassian/shared/**/*.md",
"skills/create-plan/**/*.md",
"skills/do-task/**/*.md",
"skills/implement-plan/**/*.md",
"skills/reviewer-runtime/**/*.md",
"skills/web-automation/codex/**/*.md",
"skills/web-automation/pi/**/*.md",
"skills/web-automation/claude-code/SKILL.md",
"skills/web-automation/cursor/SKILL.md",
"skills/web-automation/opencode/SKILL.md",
"skills/atlassian/codex/SKILL.md",
"skills/atlassian/claude-code/SKILL.md",
"skills/atlassian/cursor/SKILL.md",
"skills/atlassian/opencode/SKILL.md",
"skills/atlassian/pi/SKILL.md"
],
// Ignore patterns — always exclude node_modules, generated artefacts, and canonical _source dirs
"ignores": [
"**/node_modules/**",
"pi-package/**",
// Canonical source directories — contain per-agent source with relative links
// calibrated to their generated location (one level up); exclude from linting.
"skills/**/_source/**"
]
}
+37
View File
@@ -0,0 +1,37 @@
// markdownlint configuration — ai-coding-skills (M1)
//
// Pre-existing violations in docs/ and skills/ SKILL.md files are recorded
// in docs/CLEANUP-BASELINE.md and deferred to a later milestone for fixing.
// New markdown files added from M1 onward must satisfy all enabled rules.
{
// Inherit all default rules, then override below
"default": true,
// MD013 — line length
// This project contains long technical strings, code snippets, and URLs
// in markdown prose. Enforce a generous limit rather than the strict 80-char
// default to avoid noise on otherwise-clean documents.
"MD013": {
"line_length": 120,
"heading_line_length": 120,
"code_block_line_length": 160,
"code_blocks": false,
"tables": false
},
// MD033 — inline HTML
// Allow HTML in markdown (used in some SKILL.md files for tables/details).
"MD033": false,
// MD034 — bare URLs
// Disabled: existing docs include many plain URLs intentionally.
"MD034": false,
// MD041 — first line should be top-level heading
// Disabled: some files intentionally start with front-matter or a preamble.
"MD041": false,
// MD060 — table column style
// Disabled: existing tables use compact pipe style throughout.
"MD060": false
}
+14
View File
@@ -0,0 +1,14 @@
node_modules/
**/node_modules/**
pnpm-lock.yaml
# Generated agent-variant directories (non-mutating M1 policy)
skills/atlassian/codex/
skills/atlassian/claude-code/
skills/atlassian/cursor/
skills/atlassian/opencode/
skills/atlassian/pi/
skills/web-automation/claude-code/
skills/web-automation/cursor/
skills/web-automation/opencode/
skills/web-automation/pi/
pi-package/
+13
View File
@@ -0,0 +1,13 @@
node_modules/
pnpm-lock.yaml
# Generated agent-variant directories (non-mutating M1 policy)
skills/atlassian/codex/
skills/atlassian/claude-code/
skills/atlassian/cursor/
skills/atlassian/opencode/
skills/atlassian/pi/
skills/web-automation/claude-code/
skills/web-automation/cursor/
skills/web-automation/opencode/
skills/web-automation/pi/
pi-package/
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "lf"
}
+204
View File
@@ -0,0 +1,204 @@
# Changelog — ai-coding-skills
All notable changes to the ai-coding-skills repository are recorded here.
Entries are milestone-scoped; stories within each milestone are listed for traceability.
---
## M5 — Final hardening, CI surface, and documentation closeout
### What changed in M5
**S-501 — CI workflow added**
`.github/workflows/check.yml` runs on every push and pull request against a
matrix of `ubuntu-latest` and `macos-latest`. Each job:
1. Installs `shellcheck` (apt on Ubuntu, brew on macOS).
2. Installs `ripgrep` (apt on Ubuntu; pre-installed on macOS runners).
3. Installs Node.js 22 and pnpm 10.
4. Runs `pnpm install --frozen-lockfile`.
5. Runs `pnpm run check` (full quality suite, offline link-checking).
`.github/workflows/check-online.yml` runs `verify:docs:online` on a weekly
schedule (Monday 09:00 UTC) and on manual `workflow_dispatch`. This keeps
external link checking out of the push/PR critical path.
**S-502 — Root README contributing section**
`README.md` now includes a "Contributing / Development" section that names
`pnpm run check` as the single quality gate, lists prerequisites, and links
to `docs/DEVELOPMENT.md`.
**S-503 — `docs/DEVELOPMENT.md` finalised**
Added:
- **"Adding a new agent variant"** recipe (6 steps from canonical source to CI).
- **"Adding a new skill"** recipe (6 steps).
- **"CI"** section describing both workflows and how to add new prerequisites.
- **Permanent `check` contract** replacing the transitional M1 wording.
- Cross-reference links to `.github/workflows/check.yml` and
`.github/workflows/check-online.yml`.
**S-504 — Baseline report closed**
`docs/CLEANUP-BASELINE.md` updated with a "Final state" section showing every
baseline failure resolved, a baseline status summary table, and the
post-M5 `pnpm run check` output. The document is now closed.
**S-505 — `package.json` file list synced**
`docs/DEVELOPMENT.md` and `docs/REVIEWERS.md` added to the `files` array.
All listed entries verified to exist in the post-M5 tree.
**S-506 — Changelog extended (this entry)**
**S-507 — Final green run confirmed**
`pnpm run check` exits 0 on macOS 15 (arm64) with Node 22.14.0 and
pnpm 10.18.1. CI workflow exercises the same gate on both `ubuntu-latest`
and `macos-latest`.
### `pnpm run check` status after M5
```text
PASS lint
PASS typecheck
PASS test
PASS verify:pi
PASS verify:reviewers
PASS verify:docs
PASS verify:generated
```
All checks green. CI enforces this on every push and PR.
---
## M4 — Shared-abstraction extraction and dead-code removal
*See `docs/CLEANUP-BASELINE.md` § "Post-M4 state" for the full M4 delta.*
---
## M3 — Shared-source generator for agent variants
### Package metadata change ⚠️
**All generated agent-variant `package.json` files have been renamed to unique
private scoped names.** This is an intentional breaking rename in the artifact
structure, but all packages carry `"private": true` and are **never published**
to any registry. End-user manual-install workflows are unaffected: copying a
`skills/<skill>/<agent>/` directory continues to work unchanged.
| Old `name` | New `name` | Path |
|-----------|-----------|------|
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-claude-code` | `skills/atlassian/claude-code/scripts/package.json` |
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-codex` | `skills/atlassian/codex/scripts/package.json` |
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-cursor` | `skills/atlassian/cursor/scripts/package.json` |
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-opencode` | `skills/atlassian/opencode/scripts/package.json` |
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-pi` | `skills/atlassian/pi/scripts/package.json` |
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-pi-mirror` | `pi-package/skills/atlassian/scripts/package.json` |
| `web-automation-scripts` | `@ai-coding-skills/web-automation-claude-code` | `skills/web-automation/claude-code/scripts/package.json` |
| `web-automation-scripts` | `@ai-coding-skills/web-automation-codex` | `skills/web-automation/codex/scripts/package.json` |
| `web-automation-scripts` | `@ai-coding-skills/web-automation-cursor` | `skills/web-automation/cursor/scripts/package.json` |
| `web-automation-scripts` | `@ai-coding-skills/web-automation-opencode` | `skills/web-automation/opencode/scripts/package.json` |
| `web-automation-scripts` | `@ai-coding-skills/web-automation-pi` | `skills/web-automation/pi/scripts/package.json` |
| `web-automation-scripts` | `@ai-coding-skills/web-automation-pi-mirror` | `pi-package/skills/web-automation/scripts/package.json` |
All renamed packages also gained `"private": true`.
### What else changed in M3
**S-301 — Canonical sources documented**
Canonical source directories introduced (all outside every generated root):
- `skills/atlassian/_source/<agent>/SKILL.md` — per-agent Atlassian skill descriptions
- `skills/web-automation/_source/<agent>/SKILL.md` — per-agent Web Automation skill descriptions
- `skills/web-automation/shared/` — shared web-automation TypeScript runtime scripts
- `skills/create-plan/_source/<agent>/` — per-agent create-plan SKILL.md + templates
- `skills/do-task/_source/<agent>/` — per-agent do-task SKILL.md + templates
- `skills/implement-plan/_source/<agent>/` — per-agent implement-plan SKILL.md
- `skills/reviewer-runtime/` (base) — canonical `run-review.sh` and `notify-telegram.sh`
**S-302 — Generator built**
`scripts/generate-skills.mjs` regenerates all 31 generated roots from canonical
sources in one pass. Exports `detectFileType`, `applyHeader`,
`makePackageJsonContent`, `getGeneratedRoots`, `buildManifest`, and
`generateSkills`. 35 unit tests.
**S-303 — Manual-workflow skills migrated**
`create-plan`, `do-task`, `implement-plan` agent variants now generated from
`_source/` canonical sources. Only diffs vs pre-migration: file-type-aware
headers and new `.generated-manifest.json` files.
**S-304 — Web-automation runtime scripts migrated**
`skills/web-automation/shared/` created from the former codex canonical source.
All five web-automation agent variants and the pi-package mirror now generated
from this shared location.
**S-305 — Reviewer-runtime Pi variants migrated**
`skills/reviewer-runtime/pi/run-review.sh` and `notify-telegram.sh` are now
generated from the canonical base scripts. The "keep this file in sync"
comments are replaced by generated-file headers.
**S-306 — `sync:pi` and `verify:generated` implemented**
- `pnpm run sync:pi` now calls `node scripts/generate-skills.mjs`.
- `scripts/verify-generated.mjs` implements the full comparison contract:
walks declared roots only, uses `.generated-manifest.json` as oracle, reports
structured diffs, exits non-zero on any mismatch. 5 unit tests including the
required `_source/` stray-file boundary test.
- `pnpm run verify:generated` wired to the real implementation.
**S-307 — Workspace updated**
`pnpm-workspace.yaml` updated to **include** all generated agent-variant
packages (now uniquely named) alongside the canonical sources. The M1
negative-glob exclusions are replaced by positive includes.
**S-308 — Documentation updated**
- `docs/DEVELOPMENT.md`: new "How variants are generated" section with
canonical source table, generated-root list, contributor workflow, header
policy table, and manifest contract.
- `CHANGELOG.md` (this file): full rename table and story summaries.
### Byte-equivalence diff allow-list (M3 vs pre-M3 generated roots)
The only permitted diffs between the M3-generated output and the pre-M3
manually-maintained variants are:
1. **File-type-aware generated-file headers** — added per the policy table in
`docs/DEVELOPMENT.md`.
2. **New `.generated-manifest.json` files** — one per generated root.
3. **`package.json` `name` field** — renamed to `@ai-coding-skills/<skill>-<agent>`.
4. **`"private": true` added** — to each generated `package.json`.
No other content diffs are permitted; `verify:generated` and the generator
itself reject any other change.
### `pnpm run check` status after M3
```text
PASS lint (was FAIL; all pre-existing violations fixed)
PASS typecheck
PASS test
PASS verify:pi
PASS verify:reviewers
PASS verify:docs
PASS verify:generated (was stub; now real implementation)
```
All checks green for the first time.
---
*Previous milestones (M1, M2) did not have a CHANGELOG.md entry. See
`docs/CLEANUP-BASELINE.md` for the M1 baseline and M2 delta.*
+137 -42
View File
@@ -1,6 +1,8 @@
# ai-coding-skills
Cross-agent skill collection for **Codex**, **Claude Code**, **OpenCode**, and **Cursor**.
Cross-agent skill collection for **Codex**, **Claude Code**, **OpenCode**, **Cursor**, and **pi**.
Pi package support is also included for the pi-native variants in this repo.
This repo is organized similarly to `obra/superpowers` and is designed to scale to many skills over time.
@@ -14,49 +16,36 @@ This repo is organized similarly to `obra/superpowers` and is designed to scale
```text
ai-coding-skills/
├── README.md
├── docs/
│ ├── README.md
│ ├── ATLASSIAN.md
│ ├── CREATE-PLAN.md
│ ├── IMPLEMENT-PLAN.md
│ └── WEB-AUTOMATION.md
├── docs/ — user-facing docs (see docs/README.md)
├── skills/
│ ├── _template/
│ │ └── SKILL.md
│ ├── atlassian/
│ │ ├── codex/
│ │ ├── claude-code/
│ │ ├── cursor/
│ │ ├── opencode/
│ │ └── shared/
│ │ ├── codex/ claude-code/ cursor/ opencode/ pi/ shared/
│ ├── create-plan/
│ │ ├── codex/
│ │ ├── claude-code/
│ │ ├── opencode/
│ │ └── cursor/
│ │ ├── codex/ claude-code/ cursor/ opencode/ pi/
│ ├── do-task/
│ │ ├── codex/
│ │ ├── claude-code/
│ │ ├── opencode/
│ │ └── cursor/
│ │ ├── codex/ claude-code/ cursor/ opencode/ pi/
│ ├── implement-plan/
│ │ ├── codex/
│ ├── claude-code/
│ │ ├── opencode/
│ │ └── cursor/
│ │ ├── codex/ claude-code/ cursor/ opencode/ pi/
│ ├── reviewer-runtime/
│ │ ├── pi/ — Pi-specific run-review.sh + notify-telegram.sh
│ │ └── tests/ — reviewer-runtime smoke tests
│ └── web-automation/
│ ├── codex/
│ ├── claude-code/
└── opencode/
├── .codex/
├── .claude-plugin/
├── .opencode/
│ └── plugins/
── commands/
├── hooks/
└── tests/
│ ├── codex/ claude-code/ cursor/ opencode/ pi/
├── pi-package/
└── skills/ — Pi-facing mirror synced by sync:pi
├── scripts/
│ ├── lib/ — shared Node helpers + portable.sh
│ └── tests/ — Node.js unit tests
├── package.json
── pnpm-workspace.yaml
```
## Where to Read Next
See **[docs/README.md](docs/README.md)** for the full documentation index with
ordered reading flow, one-line summaries for every doc, and links to per-agent
install guides, skill docs, Pi docs, Telegram setup, and development notes.
## Skills
| Skill | Agent Variant | Purpose | Status | Docs |
@@ -65,29 +54,135 @@ ai-coding-skills/
| atlassian | claude-code | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
| atlassian | opencode | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
| atlassian | cursor | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
| atlassian | pi | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
| create-plan | codex | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
| create-plan | claude-code | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
| create-plan | opencode | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
| create-plan | cursor | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
| create-plan | pi | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
| do-task | codex | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
| do-task | claude-code | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
| do-task | opencode | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
| do-task | cursor | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
| do-task | pi | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
| implement-plan | codex | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
| implement-plan | claude-code | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
| implement-plan | opencode | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
| implement-plan | cursor | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
| implement-plan | pi | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
| web-automation | codex | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | claude-code | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | cursor | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | opencode | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | pi | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
- Docs index: `docs/README.md`
- Atlassian guide: `docs/ATLASSIAN.md`
- Create-plan guide: `docs/CREATE-PLAN.md`
- Do-task guide: `docs/DO-TASK.md`
- Implement-plan guide: `docs/IMPLEMENT-PLAN.md`
- Web-automation guide: `docs/WEB-AUTOMATION.md`
- Start with the docs index: `docs/README.md`
- Automated install/update/remove wizard: `docs/INSTALLER.md`
- Manual install by client: `docs/CODEX.md`, `docs/CLAUDE-CODE.md`, `docs/CURSOR.md`, `docs/OPENCODE.md`, `docs/PI.md`
- Skill guides: `docs/ATLASSIAN.md`, `docs/CREATE-PLAN.md`, `docs/DO-TASK.md`, `docs/IMPLEMENT-PLAN.md`, `docs/WEB-AUTOMATION.md`
- Shared workflow setup: `docs/TELEGRAM-NOTIFICATIONS.md`, `docs/PI-SUPERPOWERS.md`, `docs/PI-COMMON-REVIEWER.md`
## Compatibility Policy
Each skill should explicitly document agent compatibility and any prerequisites directly in its own `SKILL.md`.
## Skill Manager Wizard
Use the repository skill manager to install, update/reinstall, or remove skills for supported local clients:
```bash
./scripts/manage-skills.sh
# or
node scripts/manage-skills.mjs
```
Useful non-interactive modes and examples:
```bash
# Preview detected clients and planned changes without writing files
node scripts/manage-skills.mjs --dry-run
# Emit a machine-readable operation plan from an answers file
node scripts/manage-skills.mjs --plan-only --answers answers.json
# Install/update a skill for a specific client
node scripts/manage-skills.mjs --client codex --scope global --skill create-plan --action install --yes
node scripts/manage-skills.mjs --client codex --scope global --skill create-plan --action update --yes
# Remove a skill from a specific client
node scripts/manage-skills.mjs --client claude-code --scope global --skill do-task --action remove --yes
# Install the Pi package globally or project-locally through the manager
node scripts/manage-skills.mjs --client pi --scope packageGlobal --pi-package --action install --yes
node scripts/manage-skills.mjs --client pi --scope packageLocal --pi-package --action install --yes
```
The wizard detects Codex, Claude Code, Cursor, OpenCode, and Pi, previews operations, checks
Superpowers dependencies for workflow skills, and prints a final operation report.
`ai_plan/` is gitignored local planning state used by `create-plan` and `do-task`. The skill
manager does not install, sync, or publish `ai_plan/` contents.
## Pi Package
The repo root now includes a pi package manifest that ships only the pi-specific surface:
- `pi-package/skills/*/` for the five packaged Pi skills
- `skills/reviewer-runtime/pi/`
- `docs/PI*.md`
- `scripts/manage-skills.mjs` and `scripts/manage-skills.sh`
- `scripts/generate-skills.mjs`
- `scripts/verify-pi-resources.sh`
- `scripts/verify-pi-workflows.sh`
- `scripts/verify-reviewer-support.sh`
Install it from a cloned checkout with the repo-owned one-liner:
```bash
./scripts/install-pi-package.sh --global
```
For a project-local install instead:
```bash
./scripts/install-pi-package.sh --local
```
Prerequisites:
- Node.js 20+
- `pi`
- either `pnpm` on `PATH`, or `corepack` support from the Node install
The repo pins its pnpm version in `package.json` so Corepack-backed installs resolve consistently.
Before publishing or sharing a tarball, run:
```bash
pnpm run sync:pi
npm run verify:pi
npm run verify:reviewers
npm pack --dry-run --json
```
Additional pi-specific guidance lives in [docs/PI.md](docs/PI.md).
For development prerequisites, quality tooling, and the `pnpm run check`
contract, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
## Contributing / Development
See **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)** for the full development guide.
Quick reference — the single quality gate every contributor must pass:
```bash
pnpm install
pnpm run check # lint · typecheck · test · verify:pi · verify:reviewers · verify:docs · verify:generated
```
Prerequisites: Node.js 20+, pnpm 10+, `shellcheck` (brew / apt), `ripgrep` (brew / apt).
CI runs the same `pnpm run check` command on both `ubuntu-latest` and `macos-latest`
(see `.github/workflows/check.yml`). External link checking runs separately on a
weekly schedule (`.github/workflows/check-online.yml`).
+37 -7
View File
@@ -2,13 +2,17 @@
## Purpose
Provide a portable Atlassian Cloud skill for Codex, Claude Code, Cursor Agent, and OpenCode using one shared CLI surface for common Jira and Confluence workflows.
Provide a portable Atlassian Cloud skill for Codex, Claude Code, Cursor Agent, OpenCode, and Pi
using one shared CLI surface for common Jira and Confluence workflows.
## Why This Skill Exists
The repo targets multiple agent environments with uneven MCP availability. This skill packages a consistent CLI contract so the same task-oriented workflows can be used across all supported agents without depending on MCP-specific integrations.
The repo targets multiple agent environments with uneven MCP availability. This skill packages a
consistent CLI contract so the same task-oriented workflows can be used across all supported agents
without depending on MCP-specific integrations.
The canonical runtime lives in `skills/atlassian/shared/scripts/`. Installable per-agent `scripts/` bundles are generated from that source with:
The canonical runtime lives in `skills/atlassian/shared/scripts/`. Installable per-agent
`scripts/` bundles are generated from that source with:
```bash
pnpm --dir skills/atlassian/shared/scripts sync:agents
@@ -23,7 +27,8 @@ pnpm --dir skills/atlassian/shared/scripts sync:agents
- `ATLASSIAN_EMAIL`
- `ATLASSIAN_API_TOKEN`
The `ATLASSIAN_*` values may come from the shell environment or a `.env` file in the installed agent-specific `scripts/` folder.
The `ATLASSIAN_*` values may come from the shell environment or a `.env` file in the installed
agent-specific `scripts/` folder.
Optional:
@@ -52,12 +57,15 @@ Optional:
## Command Notes
- `health` validates local configuration, probes Jira and Confluence separately, and reports one product as unavailable without masking the other.
- `health` validates local configuration, probes Jira and Confluence separately, and reports one
product as unavailable without masking the other.
- `jira-create` requires `--type`, `--summary`, and either `--project` or `ATLASSIAN_DEFAULT_PROJECT`.
- `jira-update` requires `--issue` and at least one of `--summary` or `--description-file`.
- `conf-create` requires `--title`, `--body-file`, and either `--space` or `ATLASSIAN_DEFAULT_SPACE`.
- `conf-update` requires `--page`, `--title`, and `--body-file`; it fetches the current page version before building the update payload.
- `raw --body-file` expects a workspace-scoped JSON file and is limited to validated Atlassian API prefixes.
- `conf-update` requires `--page`, `--title`, and `--body-file`; it fetches the current page
version before building the update payload.
- `raw --body-file` expects a workspace-scoped JSON file and is limited to validated Atlassian
API prefixes.
## Safety Model
@@ -117,6 +125,28 @@ cd ~/.cursor/skills/atlassian/scripts
pnpm install
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
pnpm run sync:pi
mkdir -p .pi/skills/atlassian
cp -R pi-package/skills/atlassian/* .pi/skills/atlassian/
cd .pi/skills/atlassian/scripts
pnpm install --frozen-lockfile
```
Global manual installs use `~/.pi/agent/skills/atlassian/` instead of `.pi/skills/atlassian/`.
## Verify Installation
Run in the installed `scripts/` folder:
+61
View File
@@ -0,0 +1,61 @@
# Claude Code Manual Install
## Skill Root
Claude Code's Skill tool auto-discovers skills installed under:
```bash
~/.claude/skills/<skill-name>/
```
Manual install example:
```bash
mkdir -p ~/.claude/skills/do-task
cp -R skills/do-task/claude-code/* ~/.claude/skills/do-task/
```
Use `skills/<skill>/claude-code/*` for each supported skill.
## Reviewer Runtime
```bash
mkdir -p ~/.claude/skills/reviewer-runtime
cp skills/reviewer-runtime/run-review.sh ~/.claude/skills/reviewer-runtime/
cp skills/reviewer-runtime/notify-telegram.sh ~/.claude/skills/reviewer-runtime/
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
```
## Superpowers
Workflow skills invoke Superpowers through Claude Code's skill system. Install or link the Obra Superpowers skills under:
```bash
~/.claude/skills/superpowers/
```
Example:
```bash
ln -s /absolute/path/to/obra/superpowers/skills ~/.claude/skills/superpowers
```
Verify:
```bash
test -f ~/.claude/skills/superpowers/brainstorming/SKILL.md
test -f ~/.claude/skills/superpowers/writing-plans/SKILL.md
test -f ~/.claude/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.claude/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.claude/skills/superpowers/finishing-a-development-branch/SKILL.md
test -f ~/.claude/skills/superpowers/executing-plans/SKILL.md
test -f ~/.claude/skills/superpowers/using-git-worktrees/SKILL.md
```
## Verify
```bash
claude --version
test -f ~/.claude/skills/do-task/SKILL.md
test -x ~/.claude/skills/reviewer-runtime/run-review.sh
```
+558
View File
@@ -0,0 +1,558 @@
# Cleanup Baseline — ai-coding-skills (M1)
Captured: 2026-05-03 · Platform: macOS (arm64, Apple Silicon) · Node 22.14.0 · pnpm 10.18.1
This document records the as-is state of every quality check at the start of
the refactor series. All pre-existing failures listed here have since been
resolved. As of M5 `pnpm run check` exits **0** on a clean checkout. Any
failure now represents a regression and must be fixed before merge.
---
## Byte-equivalence assertion
Before any file was modified in M1, `git status` showed a clean working tree.
After all S-101 through S-107 changes were applied, `git status` confirms:
- **Zero** modifications to any nested `package.json` under `skills/` or `pi-package/`.
- **Zero** modifications to any file under `skills/*/{codex,claude-code,cursor,opencode,pi}/`
(the generated agent-variant directories).
- All changes are confined to new root-level config files, new scripts, updated
root `package.json` (devDependencies + scripts), and new docs.
---
## 1. Pre-existing root scripts
### `pnpm run sync:pi`
```text
Synced pi package skill mirror into …/pi-package/skills.
EXIT: 0
```
### `pnpm run verify:pi`
```text
package metadata ok
pi resources verified
pi workflow skill docs verified
EXIT: 0
```
### `pnpm run verify:reviewers`
```text
reviewer support verified
EXIT: 0
```
### `pnpm run test:installer`
```text
TAP version 13
# tests 22
# pass 22
# fail 0
EXIT: 0
```
---
## 2. Nested package scripts
### atlassian/shared/scripts — `test`
```text
TAP version 13
# tests 23
# pass 23
# fail 0
EXIT: 0
```
### atlassian/shared/scripts — `typecheck`
```text
EXIT: 0
```
### atlassian/codex/scripts — `typecheck`
```text
EXIT: 0
```
### atlassian/claude-code/scripts — `typecheck`
```text
EXIT: 0
```
### atlassian/cursor/scripts — `typecheck`
```text
EXIT: 0
```
### atlassian/opencode/scripts — `typecheck`
```text
EXIT: 0
```
### atlassian/pi/scripts — `typecheck`
```text
EXIT: 0
```
### web-automation/codex/scripts — `typecheck`
```text
EXIT: 0
```
### web-automation/codex/scripts — `lint`
```text
EXIT: 0
```
### web-automation/claude-code/scripts — `typecheck`
```text
EXIT: 0
```
### web-automation/cursor/scripts — `typecheck`
```text
EXIT: 0
```
### web-automation/opencode/scripts — `typecheck`
```text
EXIT: 0
```
### web-automation/pi/scripts — `typecheck`
```text
EXIT: 0
```
---
## 3. reviewer-runtime tests
All three test scripts under `skills/reviewer-runtime/tests/` passed:
```text
claude-review-template-guard.sh PASS
smoke-test.sh PASS
telegram-notify-test.sh PASS
EXIT: 0 (each)
```
---
## 4. M1 quality tooling — baseline violations
### 4a. ESLint (`pnpm run lint` — ESLint stage)
Exit code: **1** — 2 pre-existing errors (no new errors from M1 additions).
Files with violations in the existing codebase:
| File | Line | Rule | Message |
|------|------|------|---------|
| `scripts/lib/skill-manager-core.mjs` | 282 | `no-useless-assignment` | Value assigned to `entries` is not used |
| `scripts/manage-skills.mjs` | 270 | `no-unused-vars` | `client` is assigned a value but never used |
Action: Fix in a later milestone. Do not suppress with eslint-disable comments
unless the code is intentionally dead.
### 4b. shellcheck (`pnpm run lint` — shellcheck stage)
Exit code: **1** — 7 files with pre-existing findings (no new violations from M1).
| File | Finding |
|------|---------|
| `scripts/verify-pi-resources.sh` | SC2016 (info, ×3): single-quoted backtick strings; SC2251 (info, ×1): `!` outside condition |
| `scripts/verify-pi-workflows.sh` | SC2016 (info, ×2): single-quoted backtick strings; SC2251 (info, ×1): `!` outside condition |
| `skills/reviewer-runtime/pi/run-review.sh` | SC2329 (info): unused `handle_signal`; SC2034 (warning, ×2): unused variables |
| `skills/reviewer-runtime/run-review.sh` | SC2329 (info): unused `handle_signal`; SC2034 (warning, ×2): unused variables |
| `skills/reviewer-runtime/tests/claude-review-template-guard.sh` | SC2016 (info, ×1): single-quoted expansion |
| `skills/reviewer-runtime/tests/smoke-test.sh` | SC2064 (warning, ×1): `trap` should use single quotes |
| `skills/reviewer-runtime/tests/telegram-notify-test.sh` | SC2064 (warning, ×1): `trap` should use single quotes |
The following files pass shellcheck with EXIT 0:
- `scripts/install-pi-package.sh`
- `scripts/manage-skills.sh`
- `scripts/sync-pi-package-skills.sh`
- `scripts/verify-reviewer-support.sh`
- `skills/reviewer-runtime/notify-telegram.sh`
- `skills/reviewer-runtime/pi/notify-telegram.sh`
- `skills/web-automation/scripts/sync-variants.sh`
Action: Fix `SC2064` trap issues and `SC2034` warnings in a later milestone.
SC2016 findings are intentional (single-quoted strings containing backticks are
used as literal grep patterns to match markdown text).
### 4c. markdownlint (`pnpm run verify:docs`)
Exit code: **1** — 1160 errors across 62 files (no new violations from M1 additions).
markdownlint-cli2 v0.22.1 scanned `README.md`, `docs/**/*.md`, and canonical
`SKILL.md` files (excluding node\_modules and generated agent-variant
directories).
Rule breakdown (selected):
| Rule | Description |
|------|-------------|
| MD013 | Line length > 120 chars (majority of errors) |
| MD012 | Multiple consecutive blank lines |
| MD024 | Duplicate headings |
| MD029 | Ordered list item prefix |
| MD032 | Blanks around lists |
| MD038 | Spaces inside code span |
| MD040 | Fenced code without language |
| MD058 | Blanks around tables |
Affected files include (non-exhaustive): `docs/ATLASSIAN.md`,
`docs/CREATE-PLAN.md`, `docs/DO-TASK.md`, `docs/IMPLEMENT-PLAN.md`,
`docs/INSTALLER.md`, `docs/PI-COMMON-REVIEWER.md`, `docs/PI-RESEARCH.md`,
`docs/PI-SUPERPOWERS.md`, `docs/PI.md`, `docs/README.md`,
`docs/TELEGRAM-NOTIFICATIONS.md`, `docs/WEB-AUTOMATION.md`, `README.md`,
and all `skills/*/*/SKILL.md` variants.
Action: Bulk-fix in a dedicated doc-cleanup milestone. New docs added in M1
(`docs/DEVELOPMENT.md`, `docs/CLEANUP-BASELINE.md`) pass all enabled rules.
### 4d. markdown-link-check (offline, `pnpm run verify:docs`)
Exit code: **0** — no broken repo-relative or anchor links found.
53 markdown files scanned (offline mode: external http/https links ignored).
All internal links are valid.
### 4e. `pnpm run typecheck` (workspace aggregate)
Exit code: **0**.
Ran `typecheck` in workspace members:
- `skills/atlassian/shared/scripts` — PASS
- `skills/web-automation/codex/scripts` — PASS
### 4f. `pnpm run test` (workspace aggregate)
Exit code: **0**.
Ran:
- `pnpm run test:installer` (22/22 tests)
- `skills/atlassian/shared/scripts test` (23/23 tests)
### 4g. `pnpm run check` aggregate (M1 transitional state)
```text
FAIL lint (exit 1) — ESLint + shellcheck pre-existing violations (§4a, §4b)
PASS typecheck
PASS test
PASS verify:pi
PASS verify:reviewers
FAIL verify:docs (exit 1) — markdownlint pre-existing violations (§4c)
PASS verify:generated (stub)
```
Overall exit: **1** — expected per transitional contract; all failures are
pre-existing and documented in this file.
---
## 5. Platform notes
- Tested on macOS 15 (arm64). No GNU/Linux run available at M1 capture time.
- shellcheck 0.11.0 installed via `brew install shellcheck`.
- `stat` variant: BSD stat (macOS default). The `scripts/` shell scripts do
not call `stat`; this affects only M5 CI configuration.
- `rg` (ripgrep) is required by `scripts/verify-pi-workflows.sh`. It is
present on the test machine; absent systems should install via
`brew install ripgrep` / `apt-get install ripgrep`.
---
*This file is generated once (M1) and updated only when a subsequent milestone
changes the baseline status of a check.*
---
## Post-M3 state
Captured: 2026-05-03 · Platform: macOS 15 (arm64) · Node 22.14.0 · pnpm 10.18.1
M3 resolved all remaining pre-existing `lint` violations (2 ESLint errors and
7 shellcheck findings). `verify:generated` is now a real implementation
(was a stub in M2).
### `pnpm run check` aggregate (post-M3)
```text
PASS lint (was FAIL in M1/M2; pre-existing violations fixed in M3)
PASS typecheck
PASS test
PASS verify:pi
PASS verify:reviewers
PASS verify:docs
PASS verify:generated (was stub in M2; real implementation in M3)
```
Overall exit: **0** — all checks green for the first time.
### What changed in M3
- `scripts/generate-skills.mjs` added (generator for all 31 agent-variant roots).
- `scripts/verify-generated.mjs` added (drift detector).
- Canonical source directories created: `skills/<skill>/_source/`,
`skills/web-automation/shared/`.
- All 31 generated roots now carry `.generated-manifest.json` and file-type-aware
headers.
- `package.json` renames: all generated agent-variant packages renamed to
`@ai-coding-skills/<skill>-<agent>` with `"private": true`.
- `pnpm-workspace.yaml` updated: M1 negative-glob exclusions replaced by
positive includes for all uniquely-named generated variants.
- Pre-existing ESLint violations fixed: `skill-manager-core.mjs:282`
(`no-useless-assignment`) and `manage-skills.mjs:270` (`no-unused-vars`).
- Pre-existing shellcheck findings fixed: SC2034 and SC2329 in
`reviewer-runtime/run-review.sh`; SC2064 in test trap statements;
SC2016 / SC2251 in verify scripts (suppress intentional patterns).
- `pnpm run sync:pi` now calls the generator instead of
`sync-pi-package-skills.sh`.
- `docs/DEVELOPMENT.md` extended with generation workflow and metadata docs.
- `CHANGELOG.md` created with package-metadata change callout.
### Full byte-equivalence diff (M3 vs pre-M3)
See `CHANGELOG.md` § "Byte-equivalence diff allow-list" for the complete
documented diff between M3-generated output and the pre-M3 variants. The
only permitted diffs are headers, `.generated-manifest.json`, `name` rename,
and `"private": true`.
---
## Post-M2 state
Captured: 2026-05-03 · Platform: macOS 15 (arm64) · Node 22.14.0 · pnpm 10.18.1
M2 resolved all `verify:docs` violations and the `verify:pi` portability
issue (`stat -f` BSD-ism in `scripts/verify-pi-resources.sh`).
### `pnpm run check` aggregate (post-M2)
```text
FAIL lint (exit 1) — same 2 ESLint + 7 shellcheck pre-existing violations (§4a, §4b)
PASS typecheck
PASS test
PASS verify:pi
PASS verify:reviewers
PASS verify:docs (was FAIL in M1; now clean)
PASS verify:generated (stub)
```
Overall exit: **1** — only `lint` still fails on pre-existing violations.
`verify:docs` is now green (0 markdownlint errors, offline link-check clean,
docs-flow verifier passes).
### What changed in M2
- `scripts/lib/portable.sh` added with `portable_stat_perms` helper.
- `scripts/verify-pi-resources.sh` updated to use `portable_stat_perms`
(replaces `stat -f '%Lp'` BSD-only call).
- `scripts/lib/run-shellcheck.mjs` updated to pass `-x --source-path=SCRIPTDIR`
so shellcheck follows `source` directives correctly on both platforms.
- `skills/.markdownlint.jsonc` added to disable MD013 and MD024 for
agent-facing SKILL.md files.
- All 1160 M1 markdownlint violations resolved (auto-fix + targeted edits).
- `docs/README.md` reorganized into ordered reading flow.
- `README.md` layout section updated to reflect actual repo tree.
- `docs/REVIEWERS.md` added as canonical reviewer CLI matrix.
- `docs/TELEGRAM-NOTIFICATIONS.md` extended with Pi section.
- `docs/CREATE-PLAN.md`, `docs/IMPLEMENT-PLAN.md`, `docs/DO-TASK.md` updated
with REVIEWERS.md references and unique variant headings.
- OpenCode reviewer branches added to `skills/create-plan/opencode/SKILL.md`
and `skills/implement-plan/opencode/SKILL.md`.
- `scripts/verify-docs-flow.mjs` implemented and wired into `verify:docs`.
- `pnpm run verify:docs:online` added for external link checks.
### Ubuntu smoke note
No Docker/Ubuntu run was available at M2 capture time. The portability
fix (`portable_stat_perms`) replaces the only identified BSD-ism. The
Ubuntu Docker command is documented in
[DEVELOPMENT.md](./DEVELOPMENT.md#cross-platform-shell-support-m2).
---
## Post-M4 state
Captured: 2026-05-03 · Platform: macOS 15 (arm64) · Node 22.14.0 · pnpm 10.18.1
M4 extracted reusable abstractions, consolidated shared helpers, tightened
types, and removed the legacy dead-code path. `pnpm run check` remains fully
green.
### `pnpm run check` aggregate (post-M4)
```text
PASS lint
PASS typecheck
PASS test
PASS verify:pi
PASS verify:reviewers
PASS verify:docs
PASS verify:generated
```
Overall exit: **0** — all checks green (no regressions from M3).
### What changed in M4
- **S-401** — `scripts/lib/safe-replace-dir.mjs` added: Node.js helper that
validates a target is a strict descendant of a safety root before replacing
it. Thin shell wrapper `scripts/lib/safe-replace-dir.sh` provided for
sourcing in shell scripts. `scripts/sync-pi-package-skills.sh` updated to
use `safe_replace_dir` from the shared helper (inline `replace_dir` removed).
- **S-402** — `removeTarget(op)` extracted from `executeOperation()` in
`scripts/lib/skill-manager-core.mjs` and exported. The helper handles
skill, helper, and symlink removal with idempotent semantics.
`executeOperation` now delegates to `removeTarget` for all remove branches.
- **S-403** — `skills/atlassian/shared/scripts/src/command-helpers.ts` added
with `dryRunResponse<T>()` and `resolveFormat()` helpers. `confluence.ts`,
`jira.ts`, and `raw.ts` consume `dryRunResponse` (8 inline objects removed).
`cli.ts` imports `resolveFormat` from `command-helpers` instead of defining
it locally. All atlassian agent variants regenerated.
- **S-404** — `skills/web-automation/shared/lib/browser.ts` created with
`getProfilePath`, `launchBrowser`, and `getPage`. `browse.ts` imports and
re-exports them. `auth.ts`, `flow.ts`, and `scan-local-app.ts` now import
directly from `lib/browser.js`. Generator updated to include `lib/`
directory in `scriptFiles` for web-automation variants. `tsconfig.json`
updated to include `lib/**/*.ts`.
- **S-405** — `scan-local-app.ts` `page: any` parameters replaced with
`Page` from `playwright-core`. Added `GotoError` discriminated type to
narrow the `page.goto().catch()` union type safely.
- **S-406** — `scripts/sync-pi-package-skills.sh` deleted (retired in M3,
inline `replace_dir` migrated to shared helper as part of S-401). Comment
in `skill-manager-core.mjs` referencing the deleted file updated.
Generator's `clearGeneratedRoot` fixed to preserve `node_modules` at all
depths (was only protected at root level, causing pnpm workspace packages
inside `scripts/` subdirs to lose their node_modules on regeneration).
- **S-407** — Tests added:
- `scripts/tests/safe-replace-dir.test.mjs` (6 tests for S-401 helper)
- `scripts/tests/skill-manager-core-remove.test.mjs` (5 tests for S-402)
- `skills/atlassian/shared/scripts/tests/command-helpers.test.ts` (7 tests
for S-403 `dryRunResponse` and `resolveFormat`)
### Test count (post-M4)
| Suite | Tests |
|---|---|
| `pnpm run test:installer` (root scripts) | 80 |
| `atlassian/shared/scripts` | 29 |
| **Total** | **109** |
---
## Final state (post-M5) — CLOSED
Captured: 2026-05-03 · Platform: macOS 15 (arm64) · Node 22.14.0 · pnpm 10.18.1
M5 added CI configuration, finalised documentation, closed out this baseline
report, and locked in a permanently green `pnpm run check` gate. No
pre-existing failures remain open.
### `pnpm run check` aggregate (post-M5 — macOS)
```text
PASS lint
PASS typecheck
PASS test
PASS verify:pi
PASS verify:reviewers
PASS verify:docs
PASS verify:generated
All checks passed.
```
Overall exit: **0** — all checks green (no regressions from M4).
### What changed in M5
- **S-501** — `.github/workflows/check.yml` added: matrix over `ubuntu-latest`
and `macos-latest`; installs `shellcheck` (apt/brew), `ripgrep` (apt), pnpm,
and Node.js 22; runs `pnpm run check`.
- **S-501** — `.github/workflows/check-online.yml` added: weekly schedule +
`workflow_dispatch`; runs `verify:docs:online` for full external link checking.
- **S-502** — Root `README.md` gained a "Contributing / Development" section
naming `pnpm run check` as the single quality gate and linking to
`docs/DEVELOPMENT.md`.
- **S-503** — `docs/DEVELOPMENT.md` finalised: added "Adding a new agent",
"Adding a new skill", and "CI" sections; transitional contract replaced by
the permanent M5 contract; baseline links updated.
- **S-504** — This file (`docs/CLEANUP-BASELINE.md`) closed out with the
"Final state" section.
- **S-505** — Root `package.json` `files` list updated: `docs/DEVELOPMENT.md`
and `docs/REVIEWERS.md` added.
- **S-506** — `CHANGELOG.md` extended with the M5 consolidated entry.
- **S-507** — Final `pnpm run check` run confirmed green (output above).
### Baseline status summary
Every pre-existing failure recorded in this document is now **resolved**.
No waivers are required.
| Check | M1 status | Final status | Resolution |
|-------|-----------|--------------|------------|
| `lint` (ESLint) | FAIL — 2 errors | **PASS** | Fixed in M3 (S-302) |
| `lint` (shellcheck) | FAIL — 7 findings | **PASS** | Fixed in M3 (S-302) |
| `typecheck` | PASS | **PASS** | Never regressed |
| `test` | PASS | **PASS** | Never regressed |
| `verify:pi` | PASS | **PASS** | Portability hardened in M2 |
| `verify:reviewers` | PASS | **PASS** | Never regressed |
| `verify:docs` (markdownlint) | FAIL — 1160 errors | **PASS** | Fixed in M2 |
| `verify:docs` (link-check) | PASS | **PASS** | Never regressed |
| `verify:generated` | PASS (stub) | **PASS** | Real implementation in M3 |
### Ubuntu smoke test
The CI workflow (`.github/workflows/check.yml`) runs the same `pnpm run check`
command on `ubuntu-latest`. This replaces the Docker one-liner that was the
only documented Ubuntu smoke test path in M1M4.
For local reproduction on Linux:
```bash
# Requires Docker
docker run --rm \
-v "$PWD:/w" \
-w /w \
node:22-bookworm \
bash -lc 'apt-get update -q && apt-get install -y -q shellcheck ripgrep python3 \
&& corepack enable \
&& pnpm install --frozen-lockfile \
&& pnpm run check'
```
*This baseline report is now closed. Future regressions must be caught by CI
and fixed before merge; they should NOT reopen this document.*
+63
View File
@@ -0,0 +1,63 @@
# Codex Manual Install
## Skill Root
Codex skills are installed under:
```bash
~/.codex/skills/<skill-name>/
```
Manual install example:
```bash
mkdir -p ~/.codex/skills/create-plan
cp -R skills/create-plan/codex/* ~/.codex/skills/create-plan/
```
Repeat with `skills/<skill>/codex/*` for `atlassian`, `create-plan`, `do-task`, `implement-plan`, and `web-automation`.
## Reviewer Runtime
Workflow skills need the shared runtime helpers:
```bash
mkdir -p ~/.codex/skills/reviewer-runtime
cp skills/reviewer-runtime/run-review.sh ~/.codex/skills/reviewer-runtime/
cp skills/reviewer-runtime/notify-telegram.sh ~/.codex/skills/reviewer-runtime/
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
```
## Superpowers
Workflow skills require Obra Superpowers. Codex variants expect the shared root:
```bash
mkdir -p ~/.agents/skills
ln -s /absolute/path/to/obra/superpowers/skills ~/.agents/skills/superpowers
```
Verify planning skills:
```bash
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md
```
Verify execution skills:
```bash
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md
test -f ~/.agents/skills/superpowers/executing-plans/SKILL.md
test -f ~/.agents/skills/superpowers/using-git-worktrees/SKILL.md
```
## Verify
```bash
test -f ~/.codex/skills/create-plan/SKILL.md
test -x ~/.codex/skills/reviewer-runtime/run-review.sh
codex --version
```
+111 -34
View File
@@ -2,7 +2,8 @@
## Purpose
Create structured implementation plans with milestone and story tracking, and optionally review them iteratively with a second model/provider before finalizing.
Create structured implementation plans with milestone and story tracking, and optionally review
them iteratively with a second model/provider before finalizing.
## Requirements
@@ -12,31 +13,41 @@ Create structured implementation plans with milestone and story tracking, and op
- `superpowers:writing-plans`
- For Codex, native skill discovery must be configured:
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
- For Cursor, skills must be installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global)
- Cursor can use the Cursor Superpowers plugin cache or manual `.cursor/skills/superpowers/skills`
/ `~/.cursor/skills/superpowers/skills` installs.
- OpenCode can use `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`.
- Shared reviewer runtime must be installed beside agent skills when using reviewer CLIs:
- Codex: `~/.codex/skills/reviewer-runtime/run-review.sh`
- Claude Code: `~/.claude/skills/reviewer-runtime/run-review.sh`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/run-review.sh`
- Cursor: `.cursor/skills/reviewer-runtime/run-review.sh` or `~/.cursor/skills/reviewer-runtime/run-review.sh`
- Codex: `~/.codex/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Cursor: `.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` or `~/.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Pi: `.pi/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` or `~/.pi/agent/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}`
- Telegram notification setup is documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
If dependencies are missing, stop and return:
"Missing dependency: native Superpowers skills are required (`superpowers:brainstorming`, `superpowers:writing-plans`). Install from https://github.com/obra/superpowers, then retry."
"Missing dependency: native Superpowers skills are required (`superpowers:brainstorming`,
`superpowers:writing-plans`). Install from https://github.com/obra/superpowers, then retry."
### Reviewer CLI Requirements (Optional)
To use the iterative plan review feature, one of these CLIs must be installed:
The canonical reviewer CLI support matrix is documented in
[REVIEWERS.md](./REVIEWERS.md). To use the iterative plan review feature, one
of these CLIs must be installed:
| Reviewer CLI | Install | Verify |
|---|---|---|
| `codex` | `npm install -g @openai/codex` | `codex --version` |
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --version` |
| `cursor` | `curl https://cursor.com/install -fsS \| bash` | `cursor-agent --version` (binary: `cursor-agent`; alias `cursor agent` also works) |
| `opencode` | `brew install opencode` or your package manager | `opencode --version` |
| `pi` | Install Pi coding agent | `pi --version`; list models with `pi --list-models [search]` |
The reviewer CLI is independent of which agent is running the planning — e.g., Claude Code can send plans to Codex for review, and vice versa.
The reviewer CLI is independent of which agent is running the planning — e.g., Claude Code can
send plans to Codex for review, and vice versa.
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output. Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output.
Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
## Install
@@ -46,7 +57,8 @@ The reviewer CLI is independent of which agent is running the planning — e.g.,
mkdir -p ~/.codex/skills/create-plan
cp -R skills/create-plan/codex/* ~/.codex/skills/create-plan/
mkdir -p ~/.codex/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.codex/skills/reviewer-runtime/
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
```
### Claude Code
@@ -55,7 +67,8 @@ cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
mkdir -p ~/.claude/skills/create-plan
cp -R skills/create-plan/claude-code/* ~/.claude/skills/create-plan/
mkdir -p ~/.claude/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.claude/skills/reviewer-runtime/
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
```
### OpenCode
@@ -64,7 +77,8 @@ cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
mkdir -p ~/.config/opencode/skills/create-plan
cp -R skills/create-plan/opencode/* ~/.config/opencode/skills/create-plan/
mkdir -p ~/.config/opencode/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.config/opencode/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.config/opencode/skills/reviewer-runtime/
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
```
### Cursor
@@ -75,7 +89,8 @@ Copy into the repo-local `.cursor/skills/` directory (where the Cursor Agent CLI
mkdir -p .cursor/skills/create-plan
cp -R skills/create-plan/cursor/* .cursor/skills/create-plan/
mkdir -p .cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* .cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh .cursor/skills/reviewer-runtime/
chmod +x .cursor/skills/reviewer-runtime/*.sh
```
Or install globally (loaded via `~/.cursor/skills/`):
@@ -84,9 +99,35 @@ Or install globally (loaded via `~/.cursor/skills/`):
mkdir -p ~/.cursor/skills/create-plan
cp -R skills/create-plan/cursor/* ~/.cursor/skills/create-plan/
mkdir -p ~/.cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.cursor/skills/reviewer-runtime/
chmod +x ~/.cursor/skills/reviewer-runtime/*.sh
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
pnpm run sync:pi
mkdir -p .pi/skills/create-plan
cp -R pi-package/skills/create-plan/* .pi/skills/create-plan/
mkdir -p .pi/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* .pi/skills/reviewer-runtime/pi/
chmod +x .pi/skills/reviewer-runtime/pi/*.sh
```
Global manual installs use `~/.pi/agent/skills/create-plan/` and `~/.pi/agent/skills/reviewer-runtime/pi/` instead of `.pi/skills/...`.
Pi workflow skills also require Superpowers. See [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md).
## Verify Installation
```bash
@@ -94,10 +135,12 @@ test -f ~/.codex/skills/create-plan/SKILL.md || true
test -f ~/.claude/skills/create-plan/SKILL.md || true
test -f ~/.config/opencode/skills/create-plan/SKILL.md || true
test -f .cursor/skills/create-plan/SKILL.md || test -f ~/.cursor/skills/create-plan/SKILL.md || true
test -f .pi/skills/create-plan/SKILL.md || test -f ~/.pi/agent/skills/create-plan/SKILL.md || true
test -x ~/.codex/skills/reviewer-runtime/run-review.sh || true
test -x ~/.claude/skills/reviewer-runtime/run-review.sh || true
test -x ~/.config/opencode/skills/reviewer-runtime/run-review.sh || true
test -x .cursor/skills/reviewer-runtime/run-review.sh || test -x ~/.cursor/skills/reviewer-runtime/run-review.sh || true
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh || true
```
Verify Superpowers dependencies exist in your agent skills root:
@@ -106,10 +149,14 @@ Verify Superpowers dependencies exist in your agent skills root:
- Codex: `~/.agents/skills/superpowers/writing-plans/SKILL.md`
- Claude Code: `~/.claude/skills/superpowers/brainstorming/SKILL.md`
- Claude Code: `~/.claude/skills/superpowers/writing-plans/SKILL.md`
- OpenCode: `~/.config/opencode/skills/superpowers/brainstorming/SKILL.md`
- OpenCode: `~/.config/opencode/skills/superpowers/writing-plans/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/brainstorming/SKILL.md` or `~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/writing-plans/SKILL.md` or `~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md`
- OpenCode: `~/.agents/skills/superpowers/brainstorming/SKILL.md` or `~/.config/opencode/skills/superpowers/brainstorming/SKILL.md`
- OpenCode: `~/.agents/skills/superpowers/writing-plans/SKILL.md` or `~/.config/opencode/skills/superpowers/writing-plans/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/brainstorming/SKILL.md`,
`~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md`, or the Cursor Superpowers plugin cache
- Cursor: `.cursor/skills/superpowers/skills/writing-plans/SKILL.md`,
`~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md`, or the Cursor Superpowers plugin cache
- Pi: `.pi/skills/superpowers/brainstorming/SKILL.md` or `~/.pi/agent/skills/superpowers/brainstorming/SKILL.md` or `~/.agents/skills/superpowers/brainstorming/SKILL.md`
- Pi: `.pi/skills/superpowers/writing-plans/SKILL.md` or `~/.pi/agent/skills/superpowers/writing-plans/SKILL.md` or `~/.agents/skills/superpowers/writing-plans/SKILL.md`
## Key Behavior
@@ -118,11 +165,14 @@ Verify Superpowers dependencies exist in your agent skills root:
- Commits `.gitignore` update locally when added.
- Asks which reviewer CLI, model, and max rounds to use (or accepts `skip` for no review).
- Iteratively reviews the plan with the chosen reviewer (default max 10 rounds) before generating files.
- Runs reviewer commands through `reviewer-runtime/run-review.sh` when available, with fallback to direct synchronous execution only if the helper is missing.
- Runs reviewer commands through `reviewer-runtime/run-review.sh` when available, with fallback to
direct synchronous execution only if the helper is missing.
- Waits as long as the reviewer runtime keeps emitting per-minute `In progress N` heartbeats.
- Requires reviewer findings to be ordered `P0` through `P3`, with `P3` explicitly non-blocking.
- Captures reviewer stderr and helper status logs for diagnostics and retains them on failed, empty-output, or operator-decision review rounds.
- Sends completion notifications through Telegram only when the shared setup in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
- Captures reviewer stderr and helper status logs for diagnostics and retains them on failed,
empty-output, or operator-decision review rounds.
- Sends completion notifications through Telegram only when the shared setup in
[TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
- Produces:
- `original-plan.md`
- `final-transcript.md`
@@ -134,10 +184,12 @@ Verify Superpowers dependencies exist in your agent skills root:
After the plan is created (design + milestones + stories), the skill sends it to a second model for review:
1. **Configure** — user picks a reviewer CLI (`codex`, `claude`, `cursor`), a model, and optional max rounds (default 10), or skips
1. **Configure** — user picks a reviewer CLI (`codex`, `claude`, `cursor`, `opencode`, `pi`), a
model, and optional max rounds (default 10), or skips
2. **Prepare** — plan payload and a bash reviewer command script are written to temp files
3. **Run** — the command script is executed through `reviewer-runtime/run-review.sh` when installed
4. **Feedback** — reviewer evaluates correctness, risks, missing steps, alternatives, security, and returns `## Summary`, `## Findings`, and `## Verdict`
4. **Feedback** — reviewer evaluates correctness, risks, missing steps, alternatives, security, and
returns `## Summary`, `## Findings`, and `## Verdict`
5. **Prioritize** — findings are ordered `P0`, `P1`, `P2`, `P3`
6. **Revise** — the planning agent addresses findings in priority order and re-submits
7. **Repeat** — up to max rounds until the reviewer returns `VERDICT: APPROVED`
@@ -172,13 +224,17 @@ ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-pr
```
`in-progress` is the liveness heartbeat emitted roughly once per minute with `note="In progress N"`.
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should stop waiting if `in-progress` heartbeats continue.
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should stop
waiting if `in-progress` heartbeats continue.
### Failure Handling
- `completed-empty-output` means the reviewer exited without producing review text; surface `.stderr` and `.status`, then retry only after diagnosing the cause.
- `needs-operator-decision` means the helper reached hard-timeout escalation; surface `.status` and decide whether to extend the timeout, abort, or retry with different parameters.
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds should retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
- `completed-empty-output` means the reviewer exited without producing review text; surface
`.stderr` and `.status`, then retry only after diagnosing the cause.
- `needs-operator-decision` means the helper reached hard-timeout escalation; surface `.status` and
decide whether to extend the timeout, abort, or retry with different parameters.
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds
should retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
- As long as fresh `in-progress` heartbeats continue to arrive roughly once per minute, the caller should keep waiting.
### Supported Reviewer CLIs
@@ -188,13 +244,30 @@ ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-pr
| `codex` | `codex exec -m <model> -s read-only` | Yes (`codex exec resume <id>`) | `-s read-only` |
| `claude` | `claude -p --model <model> --strict-mcp-config --setting-sources user` | No (fresh call each round) | `--strict-mcp-config --setting-sources user` |
| `cursor` | `cursor-agent -p --mode=ask --model <model> --trust --output-format json` | Yes (`--resume <id>`) | `--mode=ask` |
| `opencode` | `opencode run -m <provider>/<model> --agent plan --format json` | Fresh call default; optional `-s <id>` | `--agent plan` |
| `pi` | See [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) | No (fresh call each round) | `--tools read,grep,find,ls` |
For all three CLIs, the preferred execution path is:
For all supported reviewer CLIs, the preferred execution path is:
1. write the reviewer command to a bash script
2. run that script through `reviewer-runtime/run-review.sh`
3. fall back to direct synchronous execution only if the helper is missing or not executable
## Pi Reviewer Support
All workflow variants can use Pi itself as a reviewer CLI. Use `pi/<pi-model-name>` shorthand, for
example `pi/claude-opus-4-7`; this means `REVIEWER_CLI=pi` and `REVIEWER_MODEL=claude-opus-4-7`.
Provider-qualified or multi-slash Pi model IDs are preserved after the first `pi/` prefix, for
example `pi/anthropic/claude-opus-4-7`.
The canonical isolated read-only Pi reviewer flag contract lives in
[PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md). This workflow passes the plan review payload at
`/tmp/plan-${REVIEW_ID}.md` and expects the standard `## Summary`, `## Findings`, and `## Verdict`
response. Pi reviewer output is captured as markdown stdout, not JSON.
If the Pi reviewer model or provider is unavailable, surface the helper stderr/status and use
`pi --list-models [search]` to inspect configured models.
## Notifications
- Telegram is the only supported notification path.
@@ -207,17 +280,20 @@ For all three CLIs, the preferred execution path is:
All plan templates now include guardrail sections that enforce:
**Planning Guardrails** (`milestone-plan.md`):
- Design validation before implementation planning
- Native skill discovery (no deprecated CLI wrappers)
- Milestone verification + local commits + explicit approval
**Tracking Guardrails** (`story-tracker.md`):
- Immediate status updates when work starts/completes
- Explicit user approval at each milestone boundary
- No pushes until all milestones approved
- Synchronization between tracker and plan files
**Skill Workflow Guardrails** (`continuation-runbook.md`):
- Native skill invocation before action
- Explicit skill announcements
- Checklist tracking for driven skills
@@ -225,7 +301,7 @@ All plan templates now include guardrail sections that enforce:
## Variant Hardening Notes
### Claude Code
### Claude Code Hardening
- Must invoke explicit required sub-skills:
- `superpowers:brainstorming`
@@ -234,7 +310,7 @@ All plan templates now include guardrail sections that enforce:
- if in plan mode, instruct user to exit plan mode before generating files
- Must copy `original-plan.md` from `~/.claude/plans/` when available.
### Codex
### Codex Hardening
- Must use native skill discovery from `~/.agents/skills/` (no CLI wrappers).
- Must verify Superpowers skills symlink: `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
@@ -244,18 +320,19 @@ All plan templates now include guardrail sections that enforce:
- Must track checklist-driven skills with `update_plan` todos.
- Deprecated CLI commands (`superpowers-codex bootstrap`, `use-skill`) must NOT be used.
### OpenCode
### OpenCode Hardening
- Must use OpenCode native skill tool (not Claude/Codex invocation syntax).
- Must verify Superpowers skill discovery under:
- `~/.agents/skills/superpowers`
- `~/.config/opencode/skills/superpowers`
- Must explicitly load:
- `superpowers/brainstorming`
- `superpowers/writing-plans`
### Cursor
### Cursor Hardening
- Must use workspace discovery from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global).
- Must use Cursor-native discovery from `.cursor/skills/`, `~/.cursor/skills/`, or installed Cursor plugin cache entries.
- Must announce skill usage explicitly before invocation.
- Must use `--mode=ask` (read-only) and `--trust` when running reviewer non-interactively.
- Must not use `--force` or `--mode=agent` for review (reviewer should never write files).
+101
View File
@@ -0,0 +1,101 @@
# Cursor Manual Install
## Skill Roots
Cursor supports repo-local and global skills:
```bash
.cursor/skills/<skill-name>/
~/.cursor/skills/<skill-name>/
```
Manual repo-local install example:
```bash
mkdir -p .cursor/skills/create-plan
cp -R skills/create-plan/cursor/* .cursor/skills/create-plan/
```
Global install example:
```bash
mkdir -p ~/.cursor/skills/create-plan
cp -R skills/create-plan/cursor/* ~/.cursor/skills/create-plan/
```
Cursor variants exist for `atlassian`, `create-plan`, `do-task`, `implement-plan`, and `web-automation`.
Web automation repo-local install:
```bash
mkdir -p .cursor/skills/web-automation
cp -R skills/web-automation/cursor/* .cursor/skills/web-automation/
cd .cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global web automation installs use `~/.cursor/skills/web-automation/` instead.
## Reviewer Runtime
Repo-local:
```bash
mkdir -p .cursor/skills/reviewer-runtime
cp skills/reviewer-runtime/run-review.sh .cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/notify-telegram.sh .cursor/skills/reviewer-runtime/
chmod +x .cursor/skills/reviewer-runtime/*.sh
```
Global uses `~/.cursor/skills/reviewer-runtime/` instead.
## Superpowers
Cursor can discover Superpowers from the Cursor plugin cache or from manual repo-local/global
skill roots. Prefer the plugin install when it is present; do not also install a manual Superpowers
copy, or Cursor may show each Superpowers skill twice.
```bash
~/.cursor/plugins/cache/cursor-public/superpowers/<revision>/skills/<superpower>/SKILL.md
.cursor/skills/superpowers/skills/<superpower>/SKILL.md
~/.cursor/skills/superpowers/skills/<superpower>/SKILL.md
```
Manual symlink, only when the Cursor plugin is not installed:
```bash
mkdir -p .cursor/skills/superpowers
ln -s /absolute/path/to/obra/superpowers/skills .cursor/skills/superpowers/skills
```
## Cursor Reviewer Notes
Cursor reviewer calls use JSON output and require `jq` where the skill variant parses reviewer responses:
```bash
cursor-agent --version
jq --version
```
Reviewer calls must use `--mode=ask --trust --output-format json`, never write-capable modes.
## Verify
Repo-local scope:
```bash
cursor-agent --version
test -f .cursor/skills/create-plan/SKILL.md
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
```
Global scope:
```bash
cursor-agent --version
test -f ~/.cursor/skills/create-plan/SKILL.md
test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
```
+460
View File
@@ -0,0 +1,460 @@
# Development Guide — ai-coding-skills
This document covers prerequisites, how to run checks locally, the quality
tooling, the workspace policy, and the `pnpm run check` contract.
## Prerequisites
| Tool | Minimum version | Install |
|------|----------------|---------|
| Node.js | 20 | `fnm install 22` or `nvm install 22` |
| pnpm | 10 | `npm install -g pnpm` |
| `rg` (ripgrep) | any | `brew install ripgrep` / `apt-get install ripgrep` |
| **shellcheck** | **any** | `brew install shellcheck` / `apt-get install shellcheck` |
| `stat` (BSD or GNU) | any | pre-installed on macOS; GNU variant on Linux |
| Python 3 | 3.8+ | pre-installed on most systems |
**`shellcheck` is required.** The `pnpm run lint` script will exit 2 with a
clear error message if `shellcheck` is not on `PATH`. Every contributor must
install it before running any quality checks.
## Quick start
```bash
# Install dependencies (workspace-aware, no nested package.json modifications)
pnpm install
# Run the full quality suite
pnpm run check
```
## Individual checks
| Command | What it does |
|---------|-------------|
| `pnpm run sync:pi` | Mirror pi skill variants into `pi-package/skills/` |
| `pnpm run verify:pi` | Assert pi resource and workflow invariants |
| `pnpm run verify:reviewers` | Assert reviewer-runtime skill invariants |
| `pnpm run test:installer` | Run root-level Node.js unit tests (22 tests) |
| `pnpm run lint` | ESLint on root scripts + shellcheck on all `.sh` files |
| `pnpm run lint:fix` | Auto-fix ESLint + Prettier (do not run on pre-existing code until M2) |
| `pnpm run typecheck` | TypeScript `tsc --noEmit` in workspace packages |
| `pnpm run test` | Run all tests (root + workspace packages) |
| `pnpm run verify:docs` | markdownlint + offline link-check + docs-flow verifier |
| `pnpm run verify:docs:online` | Same as `verify:docs` but with full external link checking |
| `pnpm run verify:generated` | Assert generated output freshness (stub; fleshed out in M3) |
| `pnpm run verify:ci` | Assert CI workflow files carry no pnpm version pins (see [pnpm version pinning](#pnpm-version-pinning)) |
| `pnpm run check` | Aggregate: run every gate above and report a summary |
## Quality tooling (added in M1)
### ESLint
Root-level flat config in `eslint.config.mjs`. Covers `scripts/**/*.mjs`
and `scripts/**/*.js`. Uses `@eslint/js` recommended rules and Node.js
globals. Nested workspace packages are responsible for their own ESLint
configuration.
### Prettier
Config in `.prettierrc.json` (print-width 100, LF line endings). Ignore
file at `.prettierignore` excludes generated agent-variant directories and
`pnpm-lock.yaml`.
### markdownlint
Config in `.markdownlint.jsonc` (rules) and `.markdownlint-cli2.jsonc`
(file globs and ignores). Key overrides vs defaults:
- `MD013` line-length relaxed to 120 chars (code blocks and tables excluded).
- `MD033` (inline HTML) disabled.
- `MD034` (bare URLs) disabled.
- `MD041` (first-line heading) disabled.
- `MD060` (table column style) disabled.
Run `pnpm run verify:docs` to lint all `README.md`, `docs/*.md`, and
`skills/**/SKILL.md` files (node\_modules excluded automatically).
### markdown-link-check
Two configs:
- `markdown-link-check.json`**offline** mode (default): ignores all
`http://` and `https://` links. Safe for local dev and CI.
- `markdown-link-check.online.json`**online** mode: checks external links
with a 10 s timeout, 2 retries, and retry-on-429. Use `--online` flag on
`scripts/lib/run-link-check.mjs`.
`pnpm run verify:docs` uses the offline config by default.
### shellcheck
Wrapper script at `scripts/lib/run-shellcheck.mjs`. Discovers every `*.sh`
file under `scripts/` and `skills/` (excluding node\_modules and generated
agent-variant directories) and runs shellcheck on each.
**Installation:**
- macOS: `brew install shellcheck`
- Debian/Ubuntu: `sudo apt-get install shellcheck`
- Other: <https://github.com/koalaman/shellcheck#installing>
The wrapper exits with code **2** (not 1) when shellcheck is missing, so CI
can distinguish "shellcheck absent" from "shellcheck found violations".
## Cross-platform shell support (M2)
All shell scripts under `scripts/` and `skills/reviewer-runtime/` that are
exercised by `verify:pi`, `verify:reviewers`, `sync:pi`, and `test:installer`
must work on both **macOS** (BSD userland) and **Ubuntu/Debian** (GNU
userland) without modification.
### BSD vs GNU differences encountered
| Feature | macOS (BSD) | Linux (GNU) | Portable form |
|---------|-------------|-------------|---------------|
| `stat` permissions | `stat -f '%Lp' <path>` | `stat -c '%a' <path>` | `portable_stat_perms` helper |
| herestrings (`<<<`) | supported (bash) | supported (bash) | OK (scripts use `#!/usr/bin/env bash`) |
| `find -E` (extended regex) | macOS-only | not available | use `grep` or POSIX `-name` |
| `sed -i ''` | macOS form | use `sed -i` on Linux | detect or avoid in-place sed |
| `date -j` (date arithmetic) | macOS-only | use `date -d` on Linux | Node helper or `date +%s` |
| `readlink -f` | not on macOS by default | GNU standard | `realpath` or `cd && pwd` |
### `scripts/lib/portable.sh`
A shared helper that abstracts the two known BSD/GNU divergences hit in
this repo:
```bash
# Source from any script that needs portable stat
# shellcheck source=lib/portable.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib/portable.sh"
# Returns octal permission string: e.g. "755"
portable_stat_perms "$path"
```
The `shellcheck` wrapper passes `-x --source-path=SCRIPTDIR` so source
directives resolve relative to the script file, not the working directory.
### How to run the Ubuntu smoke test locally
```bash
# Requires Docker
docker run --rm \
-v "$PWD:/w" \
-w /w \
node:20-bookworm \
bash -lc 'apt-get update -q && apt-get install -y -q shellcheck ripgrep python3 \
&& corepack enable \
&& pnpm install --frozen-lockfile \
&& pnpm run check'
```
This runs the full `pnpm run check` suite (lint, typecheck, test, verify:pi,
verify:reviewers, verify:docs, verify:generated) inside a clean Debian Bookworm
container with Node 20.
## `check` contract (final — M5)
`pnpm run check` is **fully green** on a clean checkout with the documented
prerequisites installed.
```text
PASS lint
PASS typecheck
PASS test
PASS verify:pi
PASS verify:reviewers
PASS verify:docs
PASS verify:generated
PASS verify:ci
```
This is the only acceptable state for merge. Any failure on a check not in
`docs/CLEANUP-BASELINE.md` is a regression and must be fixed before merge.
> **History:** In M1 the contract was transitional and allowed pre-existing
> failures recorded in the baseline. M2 fixed `verify:docs`. M3 fixed
> `lint` and promoted `verify:generated` from stub to real implementation.
> M4 added abstractions without regressions. M5 added CI and finalized docs.
> The baseline is now closed out — see `docs/CLEANUP-BASELINE.md`.
## pnpm workspace policy (updated in M3)
The `pnpm-workspace.yaml` at the repo root uses a **positive-include** policy
introduced in M3. There are no negative-glob exclusions.
**Canonical source packages** (never generated):
- `skills/atlassian/shared/scripts` — shared Atlassian TypeScript runtime
- `skills/web-automation/shared` — shared web-automation runtime template
**Generated agent-variant packages** (uniquely named, positively included):
- `skills/atlassian/{claude-code,codex,cursor,opencode,pi}/scripts`
→ names `@ai-coding-skills/atlassian-{claude-code,codex,cursor,opencode,pi}`
- `skills/web-automation/{claude-code,codex,cursor,opencode,pi}/scripts`
→ names `@ai-coding-skills/web-automation-{claude-code,codex,cursor,opencode,pi}`
- `pi-package/skills/atlassian/scripts`
→ name `@ai-coding-skills/atlassian-pi-mirror`
- `pi-package/skills/web-automation/scripts`
→ name `@ai-coding-skills/web-automation-pi-mirror`
**Why unique names matter:**
Each package in a pnpm workspace must have a distinct `name` field. In M1 all
generated agent-variant packages shared the same non-unique name as their
canonical source package. In M3 every package receives a scoped unique name
of the form `@ai-coding-skills/<skill>-<agent>`, enabling pnpm to include them
alongside canonical source packages without conflicts. The pi-package mirrors
use the `-mirror` suffix to distinguish them from the `pi` agent variants.
All generated packages are `"private": true` and are never published to any
registry.
After `pnpm install`, `git status` should show zero modifications to any
package.json file under any generated directory. If it does not, the workspace
config or generator is broken.
## How to interpret the baseline report
See `docs/CLEANUP-BASELINE.md` for the historical as-is capture from M1. That
baseline is now **closed** — all pre-existing failures have been resolved.
When a CI run fails on any check it is a regression and must be fixed before
merge.
## How variants are generated (M3)
Every agent-variant directory under `skills/<skill>/<agent>/` and every
`pi-package/skills/<skill>/` mirror is **generator-owned**. Do not edit
files inside these directories directly — edit the canonical source instead
and run `pnpm run sync:pi` to regenerate.
### Canonical sources (non-generated)
These directories are **never** generated and are **never** inside a generated
root. They are the single source of truth for all content.
| Skill | Canonical source |
|-------|------------------|
| `atlassian` | SKILL.md: `skills/atlassian/_source/<agent>/SKILL.md`; scripts: `skills/atlassian/shared/scripts/` |
| `web-automation` | SKILL.md: `skills/web-automation/_source/<agent>/SKILL.md`; scripts: `skills/web-automation/shared/` |
| `create-plan` | `skills/create-plan/_source/<agent>/` (SKILL.md + templates) |
| `do-task` | `skills/do-task/_source/<agent>/` (SKILL.md + templates) |
| `implement-plan` | `skills/implement-plan/_source/<agent>/SKILL.md` |
| `reviewer-runtime` (base) | `skills/reviewer-runtime/run-review.sh`, `notify-telegram.sh` |
The `_source/` directories and `shared/` directories sit under the skill root
**outside** every generated root. Stale-file detection (`verify:generated`)
never traverses them.
### Generated roots (authoritative list)
All paths are relative to the repo root.
```text
skills/atlassian/{claude-code,codex,cursor,opencode,pi}/
skills/web-automation/{claude-code,codex,cursor,opencode,pi}/
skills/create-plan/{claude-code,codex,cursor,opencode,pi}/
skills/do-task/{claude-code,codex,cursor,opencode,pi}/
skills/implement-plan/{claude-code,codex,cursor,opencode,pi}/
skills/reviewer-runtime/pi/
pi-package/skills/{atlassian,create-plan,do-task,implement-plan,web-automation}/
```
### Contributor workflow
1. **Edit the canonical source** in the appropriate `_source/<agent>/`, `shared/`, or
base `skills/reviewer-runtime/` directory.
2. **Run the generator:**
```bash
pnpm run sync:pi # regenerates all 31 generated roots
```
3. **Verify no drift:**
```bash
pnpm run verify:generated # must exit 0
```
4. **Stage both the canonical source AND the generated output** in the same commit.
Never commit a canonical change without regenerating.
### File-type-aware header policy
Every generated file (except JSON and `pnpm-lock.yaml`) receives a header
identifying it as generated and pointing to the canonical source. Headers are
inserted so they do not break parsers or tools:
| File type | Header form | Placement |
|-----------|-------------|----------|
| Markdown (with YAML front matter) | `<!-- ⚠️ GENERATED FILE ... -->` | After closing `---` |
| Markdown (no front matter) | `<!-- ⚠️ GENERATED FILE ... -->` | Top of file |
| Shell script | `# ⚠️ GENERATED FILE ...` | After `#!` shebang |
| TypeScript / JavaScript | `// ⚠️ GENERATED FILE ...` | After `#!` shebang if present, else top |
| YAML (non-lockfile) | `# ⚠️ GENERATED FILE ...` | Top of file |
| JSON | *(no header — recorded in `.generated-manifest.json`)* | — |
| `pnpm-lock.yaml` | *(no header — managed by pnpm)* | — |
| JSONC | `// ⚠️ GENERATED FILE ...` | Top of file |
### `.generated-manifest.json` contract
Each generated root contains a `.generated-manifest.json` that:
- Has a `$schema` marker and a `generator` field for identification.
- Lists every **other** generator-owned path in that root (relative path,
file mode, SHA-256 hash, kind).
- **Does NOT list itself** (non-self-referential).
- Is scoped to exactly **one** generated root — there is no manifest at
`skills/<skill>/`.
- Is verified structurally (not byte-for-byte) by `verify:generated`.
### Package metadata (M3 change)
Each generated agent-variant `package.json` carries:
- A **unique private name** in the form `@ai-coding-skills/<skill>-<agent>` (e.g.
`@ai-coding-skills/atlassian-claude-code`).
- `"private": true` to prevent accidental npm publication.
The pi-package mirrors (`pi-package/skills/atlassian/scripts` and
`pi-package/skills/web-automation/scripts`) use the `-mirror` agent suffix
(`@ai-coding-skills/atlassian-pi-mirror`, `@ai-coding-skills/web-automation-pi-mirror`)
to distinguish them from the `pi` agent variants in the workspace.
This replaces the previous non-unique `name` fields (`atlassian-skill-scripts`,
`web-automation-scripts`). These packages are private and are never published.
See `CHANGELOG.md` for the full rename list.
## Adding a new agent variant
To add support for a brand-new agent (e.g. `mynewagent`) across all skills:
1. **Create canonical source files** for every skill:
```bash
mkdir -p skills/<skill>/_source/mynewagent
# Add SKILL.md (copy and adapt from an existing agent variant)
```
2. **Update the generator** (`scripts/generate-skills.mjs`):
- Add `"mynewagent"` to the `AGENTS` array (or the per-skill agent list).
- Ensure any agent-specific substitution logic is handled.
3. **Regenerate all variants:**
```bash
pnpm run sync:pi
pnpm run verify:generated # must exit 0
```
4. **Update the workspace** (`pnpm-workspace.yaml`):
- Add entries for every `skills/<skill>/mynewagent/scripts` package that
has a `package.json` (e.g. atlassian and web-automation).
5. **Update documentation:**
- Add a row to the skills table in `README.md`.
- Add a row to the reviewer matrix in `docs/REVIEWERS.md` if the agent
exposes a reviewer CLI.
- Add an install guide at `docs/MYNEWAGENT.md` and link it from
`docs/README.md`.
- Add the doc file to the `files` list in root `package.json`.
6. **Verify the full suite:**
```bash
pnpm install
pnpm run check
```
## Adding a new skill
To add an entirely new skill (e.g. `my-skill`):
1. **Create the canonical source tree:**
```bash
mkdir -p skills/my-skill/_source/{claude-code,codex,cursor,opencode,pi}
# Add SKILL.md to each _source/<agent>/ directory
```
2. **Update the generator** (`scripts/generate-skills.mjs`):
- Add `"my-skill"` to the `SKILLS` array.
- Provide a `getSkillMeta(skill)` entry with agents list, source resolver,
and any extra files to copy (templates, scripts, etc.).
3. **Regenerate:**
```bash
pnpm run sync:pi
pnpm run verify:generated
```
4. **Add the pi-package entry** if the skill should be distributed via the
Pi package:
- Update `package.json` → `pi.skills` with the new path.
- Update the `files` array to include the new skill directory.
5. **Write documentation** in `docs/MY-SKILL.md` and link from `docs/README.md`
and the skills table in `README.md`.
6. **Run the full suite:** `pnpm run check`.
## CI
Two GitHub Actions workflows live in `.github/workflows/`:
| File | Trigger | Purpose |
|------|---------|---------|
| `check.yml` | push, pull_request (all branches) | Full `pnpm run check` on `ubuntu-latest` **and** `macos-latest`. Offline link-checking (no network dependency). |
| `check-online.yml` | weekly schedule (Mon 09:00 UTC) + `workflow_dispatch` | `pnpm run verify:docs:online` with full external link checking. |
### What the `check` workflow does
1. Checks out the repository.
2. Installs `shellcheck` via `apt-get` (Ubuntu) or `brew` (macOS).
3. Installs `ripgrep` via `apt-get` (Ubuntu only; pre-installed on macOS runners).
4. Installs Node.js 22 via `actions/setup-node`.
5. Installs pnpm via `pnpm/action-setup@v4` — **no `version:` key is set**; the action reads the version from
`package.json#packageManager` (currently `pnpm@10.18.1+sha512…`), which is the single source of truth.
6. Runs `pnpm install --frozen-lockfile`.
7. Runs `pnpm run check` (the same command contributors run locally).
The matrix runs both `ubuntu-latest` and `macos-latest` to guard against
platform-specific regressions. Because M2 made all shell scripts portable
across BSD and GNU coreutils, both runners should stay green.
### pnpm version pinning
The pnpm version is pinned **exclusively** in `package.json#packageManager`:
```json
"packageManager": "pnpm@10.18.1+sha512.77a884a..."
```
This field carries an exact version *and* an integrity hash, giving stronger
reproducibility than a floating major like `version: "10"`. The
`pnpm/action-setup@v4` step in `check.yml` reads this field automatically;
do **not** add a `with.version` key to that step.
`pnpm run verify:ci` (backed by `scripts/lib/assert-no-pnpm-version-pin.mjs`)
greps every `.github/workflows/*.yml` for `pnpm/action-setup` blocks that
carry a `version:` key and fails if any are found. This prevents
reintroduction of the conflict that caused `pnpm/action-setup@v4` to error.
### Adding new prerequisites to CI
If a new tool is required (e.g. a new binary called by a verify script),
add the install step to **both** OS branches in `check.yml`. Document the
new prerequisite in the Prerequisites table at the top of this file.
## Links
- [CLEANUP-BASELINE.md](./CLEANUP-BASELINE.md) — as-is quality baseline
- [README.md](../README.md) — project overview
- [docs/README.md](./README.md) — documentation index
- [CHANGELOG.md](../CHANGELOG.md) — milestone-by-milestone change record
- [.github/workflows/check.yml](../.github/workflows/check.yml) — CI workflow
- [.github/workflows/check-online.yml](../.github/workflows/check-online.yml) — weekly online link check
+205 -70
View File
@@ -2,13 +2,19 @@
## Purpose
Execute a single user-supplied prompt end-to-end with **two reviewer loops** (plan review + implementation review), with TDD-first execution, a pre-implementation verification gate, and a single task commit — all in one run of the skill. `do-task` is scoped to small-to-medium ad-hoc tasks; for multi-milestone work use `create-plan` + `implement-plan` instead.
Execute a single user-supplied prompt end-to-end with **two reviewer loops** (plan review +
implementation review), with TDD-first execution, a pre-implementation verification gate, and a
single task commit — all in one run of the skill. `do-task` is scoped to small-to-medium ad-hoc
tasks; for multi-milestone work use `create-plan` + `implement-plan` instead.
`do-task` persists one plan artifact per run: `ai_plan/YYYY-MM-DD-<slug>/task-plan.md`. The folder is kept as a record after success (not deleted). Resume is supported via the `Status` enum and Runtime State fields.
`do-task` persists one plan artifact per run: `ai_plan/YYYY-MM-DD-<slug>/task-plan.md`. The
folder is kept as a record after success (not deleted). Resume is supported via the `Status` enum
and Runtime State fields.
## Requirements
- Git repo with `/ai_plan/` entry in `.gitignore` (the skill adds the entry automatically if missing and commits it as a separate infra commit).
- Git repo with `/ai_plan/` entry in `.gitignore` (the skill adds the entry automatically if
missing and commits it as a separate infra commit).
- Superpowers skills installed from: https://github.com/obra/superpowers
- Required dependencies (vary by variant; see Install below):
- `superpowers:brainstorming` (or `superpowers/brainstorming` for OpenCode)
@@ -18,30 +24,50 @@ Execute a single user-supplied prompt end-to-end with **two reviewer loops** (pl
- `superpowers:using-git-worktrees` (only when the prompt opts in to a worktree)
- For Codex, native skill discovery must be configured:
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
- For Cursor, skills must be installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global), and `jq` is a hard prerequisite.
- For OpenCode, Superpowers must be installed at `~/.config/opencode/skills/superpowers`.
- Shared reviewer runtime (`run-review.sh`) AND Telegram notifier helper (`notify-telegram.sh`) must be installed beside agent skills. Both scripts ship under `skills/reviewer-runtime/` in this repo and must be copied into the per-variant location:
- Cursor can use the Cursor Superpowers plugin cache or manual `.cursor/skills/superpowers/skills`
/ `~/.cursor/skills/superpowers/skills` installs, and `jq` is a hard prerequisite for the
Cursor variant.
- OpenCode can use `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`.
- Shared reviewer runtime (`run-review.sh`) AND Telegram notifier helper (`notify-telegram.sh`)
must be installed beside agent skills. Both scripts ship under `skills/reviewer-runtime/` in this
repo and must be copied into the per-variant location:
- Codex: `~/.codex/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Cursor: `.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` (repo-local, preferred) or `~/.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` (global fallback)
- Cursor: `.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` (repo-local,
preferred) or `~/.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
(global fallback)
- Pi: `.pi/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` (repo-local) or
`~/.pi/agent/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` (global)
- Variant-specific prerequisites:
- **Claude Code:** `claude --version`, explicit `Skill`-tool invocation of sub-skills.
- **Codex:** `codex --version`; `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills` symlink present.
- **Cursor:** `cursor-agent --version`, `jq --version` (hard prereq), Superpowers installed under `.cursor/skills/` or `~/.cursor/skills/`.
- **OpenCode:** `opencode --version`; Superpowers installed at `~/.config/opencode/skills/superpowers`; Phase 1 runs Bootstrap Superpowers Context.
- **Cursor:** `cursor-agent --version`, `jq --version` (hard prereq), Superpowers available
from the Cursor plugin cache or manual Cursor skill roots.
- **OpenCode:** `opencode --version`; Superpowers available from `~/.agents/skills/superpowers`
or `~/.config/opencode/skills/superpowers`; Phase 1 runs Bootstrap Superpowers Context.
- Telegram notification setup is documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
Dependency-missing messages are variant-specific:
- **Claude Code:** `Missing dependency: [specific missing item]. Install required Superpowers skills (https://github.com/obra/superpowers) and the reviewer-runtime helper, then retry.`
- **Codex:** `Missing dependency: [specific missing item]. Install required Superpowers skills (https://github.com/obra/superpowers) and the reviewer-runtime helper, then retry.`
- **Cursor:** `Missing dependency: [specific missing item]. Install Cursor Agent CLI, jq, and Superpowers skills under .cursor/skills/ or ~/.cursor/skills/, then retry.`
- **OpenCode:** `Missing dependency: [specific missing item]. Install required OpenCode Superpowers skills (https://github.com/obra/superpowers, OpenCode setup) and the reviewer-runtime helper, then retry.`
- **Claude Code:** `Missing dependency: [specific missing item]. Install required Superpowers
skills (https://github.com/obra/superpowers) and the reviewer-runtime helper, then retry.`
- **Codex:** `Missing dependency: [specific missing item]. Install required Superpowers skills
(https://github.com/obra/superpowers) and the reviewer-runtime helper, then retry.`
- **Cursor:** `Missing dependency: [specific missing item]. Install Cursor Agent CLI, jq, and the
Cursor Superpowers plugin or Superpowers skills under .cursor/skills/ or ~/.cursor/skills/,
then retry.`
- **OpenCode:** `Missing dependency: [specific missing item]. Install required OpenCode
Superpowers skills (https://github.com/obra/superpowers, OpenCode setup) and the
reviewer-runtime helper, then retry.`
- **Pi:** `Missing dependency: [specific missing item]. Install Pi, required Superpowers skills,
and the Pi reviewer-runtime helper, then retry.`
### Reviewer CLI Requirements
One of these CLIs must be installed to drive either of the two review loops:
The canonical reviewer CLI support matrix is documented in
[REVIEWERS.md](./REVIEWERS.md). One of these CLIs must be installed to drive either of the two
review loops:
| Reviewer CLI | Install | Verify | Read-Only Mode | Session Resume |
|---|---|---|---|---|
@@ -49,10 +75,14 @@ One of these CLIs must be installed to drive either of the two review loops:
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --version` | `--strict-mcp-config --setting-sources user` | No (fresh call each round) |
| `cursor` | `curl https://cursor.com/install -fsS \| bash` | `cursor-agent --version` (binary: `cursor-agent`; alias `cursor agent` also works) | `--mode=ask` | Yes (`--resume <id>`) |
| `opencode` | `brew install opencode` or your package manager | `opencode --version` | `--agent plan` | Opt-in (`-s <id>`; fresh call is the default) |
| `pi` | Install Pi coding agent | `pi --version`; list models with `pi --list-models [search]` | `--tools read,grep,find,ls` | No (fresh call each round) |
The reviewer CLI is independent of which agent is running the skill — e.g., Claude Code can send both the plan and the implementation to Codex for review.
The reviewer CLI is independent of which agent is running the skill — e.g., Claude Code can send
both the plan and the implementation to Codex for review.
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output. Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`. The cursor variant of `do-task` makes `jq` a hard prerequisite regardless of which reviewer CLI is selected.
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output.
Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`. The cursor
variant of `do-task` makes `jq` a hard prerequisite regardless of which reviewer CLI is selected.
## Install
@@ -62,7 +92,8 @@ The reviewer CLI is independent of which agent is running the skill — e.g., Cl
mkdir -p ~/.codex/skills/do-task
cp -R skills/do-task/codex/* ~/.codex/skills/do-task/
mkdir -p ~/.codex/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.codex/skills/reviewer-runtime/
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
```
### Claude Code
@@ -71,7 +102,8 @@ cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
mkdir -p ~/.claude/skills/do-task
cp -R skills/do-task/claude-code/* ~/.claude/skills/do-task/
mkdir -p ~/.claude/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.claude/skills/reviewer-runtime/
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
```
### OpenCode
@@ -80,7 +112,8 @@ cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
mkdir -p ~/.config/opencode/skills/do-task
cp -R skills/do-task/opencode/* ~/.config/opencode/skills/do-task/
mkdir -p ~/.config/opencode/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.config/opencode/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.config/opencode/skills/reviewer-runtime/
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
```
### Cursor
@@ -91,7 +124,8 @@ Copy into the repo-local `.cursor/skills/` directory (where the Cursor Agent CLI
mkdir -p .cursor/skills/do-task
cp -R skills/do-task/cursor/* .cursor/skills/do-task/
mkdir -p .cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* .cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh .cursor/skills/reviewer-runtime/
chmod +x .cursor/skills/reviewer-runtime/*.sh
```
Or install globally (loaded via `~/.cursor/skills/`):
@@ -100,14 +134,42 @@ Or install globally (loaded via `~/.cursor/skills/`):
mkdir -p ~/.cursor/skills/do-task
cp -R skills/do-task/cursor/* ~/.cursor/skills/do-task/
mkdir -p ~/.cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.cursor/skills/reviewer-runtime/
chmod +x ~/.cursor/skills/reviewer-runtime/*.sh
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
pnpm run sync:pi
mkdir -p .pi/skills/do-task
cp -R pi-package/skills/do-task/* .pi/skills/do-task/
mkdir -p .pi/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* .pi/skills/reviewer-runtime/pi/
chmod +x .pi/skills/reviewer-runtime/pi/*.sh
```
Global manual installs use `~/.pi/agent/skills/do-task/` and `~/.pi/agent/skills/reviewer-runtime/pi/` instead of `.pi/skills/...`.
Pi workflow skills also require Superpowers. See [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md).
## Verify Installation
Run the per-variant checks for everything the corresponding `SKILL.md` enforces. Each check is structured: (1) CLI binary version, (2) skill file presence, (3) reviewer-runtime + notifier helper presence, (4) Superpowers sub-skill discovery, (5) variant-specific extras.
Run the per-variant checks for everything the corresponding `SKILL.md` enforces. Each check is
structured: (1) CLI binary version, (2) skill file presence, (3) reviewer-runtime + notifier
helper presence, (4) Superpowers sub-skill discovery, (5) variant-specific extras.
### Codex
### Codex Verify
```bash
codex --version
@@ -121,7 +183,7 @@ test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md
```
### Claude Code
### Claude Code Verify
```bash
claude --version
@@ -134,21 +196,20 @@ test -f ~/.claude/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.claude/skills/superpowers/finishing-a-development-branch/SKILL.md
```
### OpenCode
### OpenCode Verify
```bash
opencode --version
test -f ~/.config/opencode/skills/do-task/SKILL.md
test -x ~/.config/opencode/skills/reviewer-runtime/run-review.sh
test -x ~/.config/opencode/skills/reviewer-runtime/notify-telegram.sh
ls -l ~/.config/opencode/skills/superpowers
test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.config/opencode/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.config/opencode/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
```
### Cursor
### Cursor Verify
```bash
cursor-agent --version
@@ -156,35 +217,66 @@ jq --version
test -f .cursor/skills/do-task/SKILL.md || test -f ~/.cursor/skills/do-task/SKILL.md
test -x .cursor/skills/reviewer-runtime/run-review.sh || test -x ~/.cursor/skills/reviewer-runtime/run-review.sh
test -x .cursor/skills/reviewer-runtime/notify-telegram.sh || test -x ~/.cursor/skills/reviewer-runtime/notify-telegram.sh
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md
test -f .cursor/skills/superpowers/skills/test-driven-development/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/test-driven-development/SKILL.md
test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/test-driven-development/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/test-driven-development/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/test-driven-development/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/verification-before-completion/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/finishing-a-development-branch/SKILL.md' -print -quit 2>/dev/null | grep -q .
```
### Pi Verify
```bash
pi --version
test -f .pi/skills/do-task/SKILL.md || test -f ~/.pi/agent/skills/do-task/SKILL.md
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh
test -f .pi/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md || test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md
test -f .pi/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.pi/agent/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md
test -f .pi/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md
test -f .pi/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md
```
## Key Behavior
- Creates one persistent plan artifact at `ai_plan/YYYY-MM-DD-<slug>/task-plan.md`.
- Ensures `/ai_plan/` is in `.gitignore`. If missing, adds it and creates a separate `chore(gitignore): ignore ai_plan local planning artifacts` commit.
- Parses the user prompt, detects the trigger phrase, and asks 1-3 clarifying questions unless the prompt already has a concrete target + outcome + unambiguous scope + resolvable identifiers.
- Invokes `superpowers:brainstorming` for any behavior-changing task (feature creation, non-trivial bug fix, refactor, design decision). The only skip conditions are `pure-documentation` and `pure-comment-whitespace-rename`.
- Asks which reviewer CLI, model, and max rounds to use (or accepts `skip` for no review). "Use defaults" maps to `codex / gpt-5.4 / MAX_ROUNDS=10`.
- Runs the plan review loop (Phase 5) before implementation, iterating up to `MAX_ROUNDS` (default 10) or until the reviewer returns `VERDICT: APPROVED`.
- Executes with TDD-first (Phase 6) via `superpowers:test-driven-development`. Auto-skip permitted only for `pure-documentation` and `pure-comment-whitespace-rename`; all other skips (including config-file additions) require explicit user approval, recorded in the TDD Approach section with an ISO-8601 timestamp.
- Ensures `/ai_plan/` is in `.gitignore`. If missing, adds it and creates a separate
`chore(gitignore): ignore ai_plan local planning artifacts` commit.
- Parses the user prompt, detects the trigger phrase, and asks 1-3 clarifying questions unless
the prompt already has a concrete target + outcome + unambiguous scope + resolvable identifiers.
- Invokes `superpowers:brainstorming` for any behavior-changing task (feature creation,
non-trivial bug fix, refactor, design decision). The only skip conditions are
`pure-documentation` and `pure-comment-whitespace-rename`.
- Asks which reviewer CLI, model, and max rounds to use (or accepts `skip` for no review).
"Use defaults" maps to `codex / gpt-5.4 / MAX_ROUNDS=10`.
- Runs the plan review loop (Phase 5) before implementation, iterating up to `MAX_ROUNDS`
(default 10) or until the reviewer returns `VERDICT: APPROVED`.
- Executes with TDD-first (Phase 6) via `superpowers:test-driven-development`. Auto-skip
permitted only for `pure-documentation` and `pure-comment-whitespace-rename`; all other skips
(including config-file additions) require explicit user approval, recorded in the TDD Approach
section with an ISO-8601 timestamp.
- Runs lint/typecheck/tests as a **verification gate** (Phase 7) before the implementation review loop.
- Runs the implementation review loop (Phase 8) against the diff + verification output, iterating up to `MAX_ROUNDS` or until `APPROVED`.
- Runs the implementation review loop (Phase 8) against the diff + verification output,
iterating up to `MAX_ROUNDS` or until `APPROVED`.
- Scans every outbound reviewer payload for secrets (subroutine step 1a). Per-payload, no caching.
- Creates a **single commit** after the implementation review approves. Does NOT push. Asks the user for explicit `yes` before any push.
- Defaults to the **current branch**. Worktree only on explicit opt-in (`"in a worktree"`, `"use a worktree"`, `"on an isolated branch"`, `"on a new branch called X"`).
- Creates a **single commit** after the implementation review approves. Does NOT push. Asks the
user for explicit `yes` before any push.
- Defaults to the **current branch**. Worktree only on explicit opt-in (`"in a worktree"`,
`"use a worktree"`, `"on an isolated branch"`, `"on a new branch called X"`).
- Supports resume: detects existing folder by slug and uses `Status` + Runtime State to decide how to re-enter.
- Sends completion notifications through Telegram only when the shared setup in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
- Sends completion notifications through Telegram only when the shared setup in
[TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
## Dual Review Loops
`do-task` runs the reviewer twice per successful run, with separate session IDs so reviewer context never leaks across loops.
1. **Plan review loop (Phase 5)** — payload is the current `task-plan.md` with `Runtime State` and `Review History` stripped. The reviewer evaluates whether the plan matches the prompt, whether assumptions are surfaced, whether acceptance criteria are testable, whether the TDD approach is appropriate, and whether there are missing files/risks/security concerns.
2. **Implementation review loop (Phase 8)** — payload is the approved task plan (without Runtime State) + `git diff` (unstaged + staged) + verification output (lint, typecheck, tests). The reviewer evaluates correctness, code quality, test coverage, security, and regression risk.
1. **Plan review loop (Phase 5)** — payload is the current `task-plan.md` with `Runtime State`
and `Review History` stripped. The reviewer evaluates whether the plan matches the prompt,
whether assumptions are surfaced, whether acceptance criteria are testable, whether the TDD
approach is appropriate, and whether there are missing files/risks/security concerns.
2. **Implementation review loop (Phase 8)** — payload is the approved task plan (without Runtime
State) + `git diff` (unstaged + staged) + verification output (lint, typecheck, tests). The
reviewer evaluates correctness, code quality, test coverage, security, and regression risk.
Both loops share the same 9-step subroutine and the same `MAX_ROUNDS` counter (default 10).
@@ -194,7 +286,10 @@ Both loops share the same 9-step subroutine and the same `MAX_ROUNDS` counter (d
2. **Secret scan (step 1a)** — per-payload, no caching. See Secret Scan section below.
3. Generate reviewer command script at `/tmp/do-task-<kind>-review-<REVIEW_ID>.sh`.
4. Run via `reviewer-runtime/run-review.sh`.
5. Promote reviewer output and capture the session ID on Round 1; persist it to `task-plan.md` Runtime State under the loop-specific variable (`CODEX_PLAN_SESSION_ID`, `CODEX_IMPL_SESSION_ID`, `CURSOR_PLAN_SESSION_ID`, `CURSOR_IMPL_SESSION_ID`, `OPENCODE_PLAN_SESSION_ID`, or `OPENCODE_IMPL_SESSION_ID`).
5. Promote reviewer output and capture the session ID on Round 1; persist it to `task-plan.md`
Runtime State under the loop-specific variable (`CODEX_PLAN_SESSION_ID`,
`CODEX_IMPL_SESSION_ID`, `CURSOR_PLAN_SESSION_ID`, `CURSOR_IMPL_SESSION_ID`,
`OPENCODE_PLAN_SESSION_ID`, or `OPENCODE_IMPL_SESSION_ID`).
6. Parse verdict; append an entry to Review History; bump the round counter.
7. Branch: `APPROVED` → exit, `REVISE` → caller revises and re-enters, `MAX_ROUNDS` → caller decides.
8. Liveness contract: wait while `In progress N` heartbeats arrive from the runner.
@@ -229,7 +324,8 @@ ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-pr
```
`in-progress` is the liveness heartbeat emitted roughly once per minute with `note="In progress N"`.
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should stop waiting if `in-progress` heartbeats continue.
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should
stop waiting if `in-progress` heartbeats continue.
### Persistent Artifact
@@ -250,19 +346,25 @@ The one file kept across runs is `ai_plan/<slug>/task-plan.md`. Its `Status` enu
## Failure Handling
- `completed-empty-output` — the reviewer exited without producing review text; surface `.stderr` and `.status`, then retry only after diagnosing the cause.
- `needs-operator-decision` — the helper reached hard-timeout escalation; surface `.status` and decide whether to extend the timeout, abort, or retry with different parameters.
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
- Verification gate (Phase 7) retries up to 3 times. On exhaustion, `Status` becomes `aborted-verification` and the user is asked whether to retry, override, or abort.
- `completed-empty-output` — the reviewer exited without producing review text; surface
`.stderr` and `.status`, then retry only after diagnosing the cause.
- `needs-operator-decision` — the helper reached hard-timeout escalation; surface `.status`
and decide whether to extend the timeout, abort, or retry with different parameters.
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds
retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
- Verification gate (Phase 7) retries up to 3 times. On exhaustion, `Status` becomes
`aborted-verification` and the user is asked whether to retry, override, or abort.
- As long as fresh `in-progress` heartbeats continue to arrive roughly once per minute, the caller keeps waiting.
## Secret Scan (subroutine step 1a; per-payload; no caching)
Every outbound reviewer payload is scanned **before** being sent to the reviewer CLI. This scan runs on every round of both loops. No results are cached, because the Phase 8 payload includes newly-introduced diff content that earlier rounds never saw.
Every outbound reviewer payload is scanned **before** being sent to the reviewer CLI. This scan
runs on every round of both loops. No results are cached, because the Phase 8 payload includes
newly-introduced diff content that earlier rounds never saw.
Canonical anchored regex list (10 patterns):
```
```text
AWS access key: AKIA[0-9A-Z]{16}
GCP service-acct: "type"\s*:\s*"service_account"
GitHub tokens: (ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{36,}
@@ -275,9 +377,14 @@ PEM private keys: -----BEGIN [A-Z ]+ PRIVATE KEY-----
JWT: eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+
```
If a match is found, the skill **redacts the matched text before showing it to the user** using the fixed token `[REDACTED:<pattern-label>:<match-length>-chars]` (pattern labels: `aws-access-key`, `gcp-service-account`, `github-token`, `slack-token`, `openai-key`, `anthropic-key`, `pem-private-key`, `dotenv-style`, `jwt`). File paths and line numbers are kept. Raw match text is never echoed to terminal, chat log, or any persistent file.
If a match is found, the skill **redacts the matched text before showing it to the user** using
the fixed token `[REDACTED:<pattern-label>:<match-length>-chars]` (pattern labels:
`aws-access-key`, `gcp-service-account`, `github-token`, `slack-token`, `openai-key`,
`anthropic-key`, `pem-private-key`, `dotenv-style`, `jwt`). File paths and line numbers are kept.
Raw match text is never echoed to terminal, chat log, or any persistent file.
The user answers `yes` / `no` / `redact`:
- `yes` — proceed; Runtime State records `last_scan_outcome_<kind>=user-approved-with-matches`.
- `redact` — the user supplies redactions, the skill applies them, and re-scans before sending. Runtime State records `last_scan_outcome_<kind>=redacted-and-approved`.
- `no` — stop the loop, set `Status: failed`, send Telegram summary.
@@ -290,20 +397,38 @@ The user answers `yes` / `no` / `redact`:
| `claude` | `claude -p "<prompt>" --model <model> --strict-mcp-config --setting-sources user` | Fresh call with prior-round context summary | `cp <runner.out> <out.md>` |
| `cursor` | `cursor-agent -p --mode=ask --model <model> --trust --output-format json "<prompt>" > <out.json>` | `cursor-agent --resume <id> -p --mode=ask --model <model> --trust --output-format json "<prompt>" > <out.json>` | `jq -r '.result' <out.json> > <out.md>` |
| `opencode` | `opencode run -m <provider>/<model> --agent plan --format json "<prompt>" > <out.json>` | Fresh call (default) OR `opencode run -s <id> -m <provider>/<model> --agent plan --format json "<prompt>" > <out.json>` (opt-in) | `jq -r '.[] \| select(.type == "message" and .role == "assistant") \| .content' <out.json> > <out.md>` |
| `pi` | See [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) | Fresh call | Markdown stdout copied to `<out.md>` |
For all four CLIs, the preferred execution path is:
For all supported reviewer CLIs, the preferred execution path is:
1. Write the reviewer command to a bash script.
2. Run that script through `reviewer-runtime/run-review.sh`.
3. Fall back to direct synchronous execution only if the helper is missing or not executable.
## Pi Reviewer Support
All workflow variants can use Pi itself as a reviewer CLI. Use `pi/<pi-model-name>` shorthand,
for example `pi/claude-opus-4-7`; this means `REVIEWER_CLI=pi` and
`REVIEWER_MODEL=claude-opus-4-7`. Provider-qualified or multi-slash Pi model IDs are preserved
after the first `pi/` prefix, for example `pi/anthropic/claude-opus-4-7`.
The canonical isolated read-only Pi reviewer flag contract lives in
[PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md). This workflow passes the plan and
implementation review payload at `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` and expects the
standard `## Summary`, `## Findings`, and `## Verdict` response. Pi reviewer output is captured
as markdown stdout, not JSON.
If the Pi reviewer model or provider is unavailable, surface the helper stderr/status and use
`pi --list-models [search]` to inspect configured models.
## Notifications
- Telegram is the only supported notification path.
- Shared setup: [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
- Notification failures are non-blocking, but they must be surfaced to the user.
- Before stopping for any user interaction, approval, or manual decision, the skill sends a Telegram summary first if configured.
- Terminal outcomes that trigger Telegram: `pushed`, `local-only`, `aborted-plan-review`, `aborted-impl-review`, `aborted-verification`, `failed`.
- Terminal outcomes that trigger Telegram: `pushed`, `local-only`, `aborted-plan-review`,
`aborted-impl-review`, `aborted-verification`, `failed`.
The reviewer-runtime helper also supports manual override flags for diagnostics:
@@ -321,7 +446,9 @@ run-review.sh \
## Template Guardrails
All four `templates/task-plan.md` files share identical core sections (14 `## `-level headings) and identical Status enum (10 values). Variant-specific guardrail language is permitted in the leading blockquote and in the `Runtime` field of the Metadata table.
All four `templates/task-plan.md` files share identical core sections (14 `##`-level headings)
and identical Status enum (10 values). Variant-specific guardrail language is permitted in the
leading blockquote and in the `Runtime` field of the Metadata table.
**Core sections** (appear in every variant, same order):
@@ -340,11 +467,15 @@ All four `templates/task-plan.md` files share identical core sections (14 `## `-
13. Final Status
14. Guardrails (do NOT remove)
**Runtime State keys** (same across all variants): `plan_review_round`, `implementation_review_round`, `CODEX_PLAN_SESSION_ID`, `CODEX_IMPL_SESSION_ID`, `CURSOR_PLAN_SESSION_ID`, `CURSOR_IMPL_SESSION_ID`, `OPENCODE_PLAN_SESSION_ID`, `OPENCODE_IMPL_SESSION_ID`, `last_phase_entered`, `last_round_ts`, `last_scan_outcome_plan`, `last_scan_outcome_impl`, `verification_attempts`, `tests_added_count`, `tdd_used`.
**Runtime State keys** (same across all variants): `plan_review_round`,
`implementation_review_round`, `CODEX_PLAN_SESSION_ID`, `CODEX_IMPL_SESSION_ID`,
`CURSOR_PLAN_SESSION_ID`, `CURSOR_IMPL_SESSION_ID`, `OPENCODE_PLAN_SESSION_ID`,
`OPENCODE_IMPL_SESSION_ID`, `last_phase_entered`, `last_round_ts`, `last_scan_outcome_plan`,
`last_scan_outcome_impl`, `verification_attempts`, `tests_added_count`, `tdd_used`.
## Variant Hardening Notes
### Claude Code
### Claude Code Hardening
- Must invoke explicit required sub-skills via the `Skill` tool:
- `superpowers:brainstorming`
@@ -355,7 +486,7 @@ All four `templates/task-plan.md` files share identical core sections (14 `## `-
- Must enforce plan-mode file-write guard in Phase 4:
- If currently in plan mode, instruct user to exit plan mode before writing `task-plan.md`.
### Codex
### Codex Hardening
- Must use native skill discovery from `~/.agents/skills/` (no CLI wrappers).
- Must verify Superpowers skills symlink: `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
@@ -366,22 +497,25 @@ All four `templates/task-plan.md` files share identical core sections (14 `## `-
- Helper paths: `~/.codex/skills/reviewer-runtime/...`.
- No plan-mode guard (Codex has no plan-mode concept).
### OpenCode
### OpenCode Hardening
- Must use OpenCode's native skill tool (not Claude's `Skill` tool syntax, not Codex's `~/.agents/skills/` paths).
- Phase 1 includes a Bootstrap Superpowers Context step that lists installed skills and confirms the required `superpowers/<skill>` set is discoverable before any other phase runs.
- Must verify Superpowers skill discovery under `~/.config/opencode/skills/superpowers`.
- Must use OpenCode's native skill tool (not Claude's `Skill` tool syntax). OpenCode may load
shared skill files from `~/.agents/skills/`, but invocation is still OpenCode-native.
- Phase 1 includes a Bootstrap Superpowers Context step that lists installed skills and confirms
the required `superpowers/<skill>` set is discoverable before any other phase runs.
- Must verify Superpowers skill discovery under `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`.
- Helper paths: `~/.config/opencode/skills/reviewer-runtime/...`.
- Opencode reviewer calls MUST use `--agent plan` (the built-in plan primary agent) for read-only posture.
- No plan-mode guard (OpenCode has no plan-mode concept).
### Cursor
### Cursor Hardening
- Must use workspace discovery from `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global).
- Must use Cursor-native discovery from `.cursor/skills/`, `~/.cursor/skills/`, or installed Cursor plugin cache entries.
- Must announce skill usage explicitly before invocation.
- `jq` is a hard prerequisite.
- Helper paths: `.cursor/skills/reviewer-runtime/...` preferred, `~/.cursor/skills/reviewer-runtime/...` fallback.
- Reviewer invocations MUST use `--mode=ask --trust --output-format json`. Never `--mode=agent`, never `--force`, never write-capable modes for reviewer calls.
- Reviewer invocations MUST use `--mode=ask --trust --output-format json`. Never `--mode=agent`,
never `--force`, never write-capable modes for reviewer calls.
- No plan-mode guard (Cursor has no plan-mode concept).
## Execution Workflow Rules
@@ -391,7 +525,8 @@ All four `templates/task-plan.md` files share identical core sections (14 `## `-
- Plan review completes before any implementation starts.
- Phase 7 verification gate must pass before the implementation review starts.
- The task commit is a single commit created in Phase 9.
- The `.gitignore` infra commit (Phase 1) is explicitly separate from the task commit and is allowed even when the final task ends up `aborted` or `failed`.
- The `.gitignore` infra commit (Phase 1) is explicitly separate from the task commit and is
allowed even when the final task ends up `aborted` or `failed`.
- No push without explicit `yes` from the user.
- Secret scan runs per-payload with no caching.
- `MAX_ROUNDS=10` is shared across both loops (single mental model).
+130 -38
View File
@@ -2,7 +2,10 @@
## Purpose
Execute an existing plan (created by `create-plan`) in an isolated git worktree, with iterative cross-model review at each milestone boundary. Milestones are implemented one-by-one with lint/typecheck/test gates, reviewed by a second model/provider, and committed locally until all are approved.
Execute an existing plan (created by `create-plan`) in an isolated git worktree, with iterative
cross-model review at each milestone boundary. Milestones are implemented one-by-one with
lint/typecheck/test gates, reviewed by a second model/provider, and committed locally until all
are approved.
## Requirements
@@ -19,12 +22,15 @@ Execute an existing plan (created by `create-plan`) in an isolated git worktree,
- `superpowers:finishing-a-development-branch`
- For Codex, native skill discovery must be configured:
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
- For Cursor, skills must be installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global)
- Cursor can use the Cursor Superpowers plugin cache or manual `.cursor/skills/superpowers/skills`
/ `~/.cursor/skills/superpowers/skills` installs.
- OpenCode can use `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`.
- Shared reviewer runtime must be installed beside agent skills when using reviewer CLIs:
- Codex: `~/.codex/skills/reviewer-runtime/run-review.sh`
- Claude Code: `~/.claude/skills/reviewer-runtime/run-review.sh`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/run-review.sh`
- Cursor: `.cursor/skills/reviewer-runtime/run-review.sh` or `~/.cursor/skills/reviewer-runtime/run-review.sh`
- Codex: `~/.codex/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Cursor: `.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` or `~/.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Pi: `.pi/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` or `~/.pi/agent/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}`
- Telegram notification setup is documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
If dependencies are missing, stop and return:
@@ -33,17 +39,23 @@ If dependencies are missing, stop and return:
### Reviewer CLI Requirements (Optional)
To use the iterative milestone review feature, one of these CLIs must be installed:
The canonical reviewer CLI support matrix is documented in
[REVIEWERS.md](./REVIEWERS.md). To use the iterative milestone review feature, one of these CLIs
must be installed:
| Reviewer CLI | Install | Verify |
|---|---|---|
| `codex` | `npm install -g @openai/codex` | `codex --version` |
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --version` |
| `cursor` | `curl https://cursor.com/install -fsS \| bash` | `cursor-agent --version` (binary: `cursor-agent`; alias `cursor agent` also works) |
| `opencode` | `brew install opencode` or your package manager | `opencode --version` |
| `pi` | Install Pi coding agent | `pi --version`; list models with `pi --list-models [search]` |
The reviewer CLI is independent of which agent is running the implementation — e.g., Claude Code can send milestones to Codex for review, and vice versa.
The reviewer CLI is independent of which agent is running the implementation — e.g., Claude Code
can send milestones to Codex for review, and vice versa.
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output. Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output.
Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
## Install
@@ -53,7 +65,8 @@ The reviewer CLI is independent of which agent is running the implementation —
mkdir -p ~/.codex/skills/implement-plan
cp -R skills/implement-plan/codex/* ~/.codex/skills/implement-plan/
mkdir -p ~/.codex/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.codex/skills/reviewer-runtime/
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
```
### Claude Code
@@ -62,7 +75,8 @@ cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
mkdir -p ~/.claude/skills/implement-plan
cp -R skills/implement-plan/claude-code/* ~/.claude/skills/implement-plan/
mkdir -p ~/.claude/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.claude/skills/reviewer-runtime/
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
```
### OpenCode
@@ -71,7 +85,8 @@ cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
mkdir -p ~/.config/opencode/skills/implement-plan
cp -R skills/implement-plan/opencode/* ~/.config/opencode/skills/implement-plan/
mkdir -p ~/.config/opencode/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.config/opencode/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.config/opencode/skills/reviewer-runtime/
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
```
### Cursor
@@ -82,7 +97,8 @@ Copy into the repo-local `.cursor/skills/` directory (where the Cursor Agent CLI
mkdir -p .cursor/skills/implement-plan
cp -R skills/implement-plan/cursor/* .cursor/skills/implement-plan/
mkdir -p .cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* .cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh .cursor/skills/reviewer-runtime/
chmod +x .cursor/skills/reviewer-runtime/*.sh
```
Or install globally (loaded via `~/.cursor/skills/`):
@@ -91,9 +107,36 @@ Or install globally (loaded via `~/.cursor/skills/`):
mkdir -p ~/.cursor/skills/implement-plan
cp -R skills/implement-plan/cursor/* ~/.cursor/skills/implement-plan/
mkdir -p ~/.cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.cursor/skills/reviewer-runtime/
chmod +x ~/.cursor/skills/reviewer-runtime/*.sh
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
pnpm run sync:pi
mkdir -p .pi/skills/implement-plan
cp -R pi-package/skills/implement-plan/* .pi/skills/implement-plan/
mkdir -p .pi/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* .pi/skills/reviewer-runtime/pi/
chmod +x .pi/skills/reviewer-runtime/pi/*.sh
```
Global manual installs use `~/.pi/agent/skills/implement-plan/` and
`~/.pi/agent/skills/reviewer-runtime/pi/` instead of `.pi/skills/...`.
Pi workflow skills also require Superpowers. See [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md).
## Verify Installation
```bash
@@ -101,10 +144,12 @@ test -f ~/.codex/skills/implement-plan/SKILL.md || true
test -f ~/.claude/skills/implement-plan/SKILL.md || true
test -f ~/.config/opencode/skills/implement-plan/SKILL.md || true
test -f .cursor/skills/implement-plan/SKILL.md || test -f ~/.cursor/skills/implement-plan/SKILL.md || true
test -f .pi/skills/implement-plan/SKILL.md || test -f ~/.pi/agent/skills/implement-plan/SKILL.md || true
test -x ~/.codex/skills/reviewer-runtime/run-review.sh || true
test -x ~/.claude/skills/reviewer-runtime/run-review.sh || true
test -x ~/.config/opencode/skills/reviewer-runtime/run-review.sh || true
test -x .cursor/skills/reviewer-runtime/run-review.sh || test -x ~/.cursor/skills/reviewer-runtime/run-review.sh || true
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh || true
```
Verify Superpowers execution dependencies exist in your agent skills root:
@@ -117,14 +162,34 @@ Verify Superpowers execution dependencies exist in your agent skills root:
- Claude Code: `~/.claude/skills/superpowers/using-git-worktrees/SKILL.md`
- Claude Code: `~/.claude/skills/superpowers/verification-before-completion/SKILL.md`
- Claude Code: `~/.claude/skills/superpowers/finishing-a-development-branch/SKILL.md`
- OpenCode: `~/.config/opencode/skills/superpowers/executing-plans/SKILL.md`
- OpenCode: `~/.config/opencode/skills/superpowers/using-git-worktrees/SKILL.md`
- OpenCode: `~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md`
- OpenCode: `~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/executing-plans/SKILL.md` or `~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md` or `~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md` or `~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md` or `~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md`
- OpenCode: `~/.agents/skills/superpowers/executing-plans/SKILL.md` or `~/.config/opencode/skills/superpowers/executing-plans/SKILL.md`
- OpenCode: `~/.agents/skills/superpowers/using-git-worktrees/SKILL.md` or `~/.config/opencode/skills/superpowers/using-git-worktrees/SKILL.md`
- OpenCode: `~/.agents/skills/superpowers/verification-before-completion/SKILL.md` or `~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md`
- OpenCode: `~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md` or `~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/executing-plans/SKILL.md`,
`~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md`, or the Cursor Superpowers
plugin cache
- Cursor: `.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md`,
`~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md`, or the Cursor Superpowers
plugin cache
- Cursor: `.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md`,
`~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md`, or the Cursor
Superpowers plugin cache
- Cursor: `.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md`,
`~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md`, or the Cursor
Superpowers plugin cache
- Pi: `.pi/skills/superpowers/executing-plans/SKILL.md` or
`~/.pi/agent/skills/superpowers/executing-plans/SKILL.md` or
`~/.agents/skills/superpowers/executing-plans/SKILL.md`
- Pi: `.pi/skills/superpowers/using-git-worktrees/SKILL.md` or
`~/.pi/agent/skills/superpowers/using-git-worktrees/SKILL.md` or
`~/.agents/skills/superpowers/using-git-worktrees/SKILL.md`
- Pi: `.pi/skills/superpowers/verification-before-completion/SKILL.md` or
`~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md` or
`~/.agents/skills/superpowers/verification-before-completion/SKILL.md`
- Pi: `.pi/skills/superpowers/finishing-a-development-branch/SKILL.md` or
`~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md` or
`~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md`
## Key Behavior
@@ -133,27 +198,31 @@ Verify Superpowers execution dependencies exist in your agent skills root:
- Executes milestones one-by-one, tracking stories in `story-tracker.md`.
- Runs lint/typecheck/tests as a gate before each milestone review.
- Sends each milestone to a reviewer CLI for approval (max rounds configurable, default 10).
- Runs reviewer commands through `reviewer-runtime/run-review.sh` when available, with fallback to direct synchronous execution only if the helper is missing.
- Runs reviewer commands through `reviewer-runtime/run-review.sh` when available, with fallback to
direct synchronous execution only if the helper is missing.
- Waits as long as the reviewer runtime keeps emitting per-minute `In progress N` heartbeats.
- Requires reviewer findings to be ordered `P0` through `P3`, with `P3` explicitly non-blocking.
- Captures reviewer stderr and helper status logs for diagnostics and retains them on failed, empty-output, or operator-decision review rounds.
- Captures reviewer stderr and helper status logs for diagnostics and retains them on failed,
empty-output, or operator-decision review rounds.
- Commits each milestone locally only after reviewer approval (does not push).
- After all milestones approved, merges worktree branch to parent and deletes worktree.
- Supports resume: detects existing worktree and `in-dev`/`completed` stories.
- Sends completion notifications through Telegram only when the shared setup in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
- Sends completion notifications through Telegram only when the shared setup in
[TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
## Milestone Review Loop
After each milestone is implemented and verified, the skill sends it to a second model for review:
1. **Configure** — user picks a reviewer CLI (`codex`, `claude`, `cursor`) and model, or skips
1. **Configure** — user picks a reviewer CLI (`codex`, `claude`, `cursor`, `opencode`, `pi`) and model, or skips
2. **Prepare** — milestone payload and a bash reviewer command script are written to temp files
3. **Run** — the command script is executed through `reviewer-runtime/run-review.sh` when installed
4. **Feedback** — reviewer evaluates correctness, acceptance criteria, code quality, test coverage, security, and returns `## Summary`, `## Findings`, and `## Verdict`
4. **Feedback** — reviewer evaluates correctness, acceptance criteria, code quality, test coverage,
security, and returns `## Summary`, `## Findings`, and `## Verdict`
5. **Prioritize** — findings are ordered `P0`, `P1`, `P2`, `P3`
6. **Revise** — the implementing agent addresses findings in priority order, re-verifies, and re-submits
7. **Repeat** — up to max rounds (default 10) until the reviewer returns `VERDICT: APPROVED`
7. **Approve** — milestone is marked approved in `story-tracker.md`
8. **Approve** — milestone is marked approved in `story-tracker.md`
### Reviewer Output Contract
@@ -184,13 +253,17 @@ ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-pr
```
`in-progress` is the liveness heartbeat emitted roughly once per minute with `note="In progress N"`.
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should stop waiting if `in-progress` heartbeats continue.
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should stop
waiting if `in-progress` heartbeats continue.
### Failure Handling
- `completed-empty-output` means the reviewer exited without producing review text; surface `.stderr` and `.status`, then retry only after diagnosing the cause.
- `needs-operator-decision` means the helper reached hard-timeout escalation; surface `.status` and decide whether to extend the timeout, abort, or retry with different parameters.
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds should retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
- `completed-empty-output` means the reviewer exited without producing review text; surface
`.stderr` and `.status`, then retry only after diagnosing the cause.
- `needs-operator-decision` means the helper reached hard-timeout escalation; surface `.status`
and decide whether to extend the timeout, abort, or retry with different parameters.
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds
should retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
- As long as fresh `in-progress` heartbeats continue to arrive roughly once per minute, the caller should keep waiting.
### Supported Reviewer CLIs
@@ -200,13 +273,31 @@ ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-pr
| `codex` | `codex exec -m <model> -s read-only` | Yes (`codex exec resume <id>`) | `-s read-only` |
| `claude` | `claude -p --model <model> --strict-mcp-config --setting-sources user` | No (fresh call each round) | `--strict-mcp-config --setting-sources user` |
| `cursor` | `cursor-agent -p --mode=ask --model <model> --trust --output-format json` | Yes (`--resume <id>`) | `--mode=ask` |
| `opencode` | `opencode run -m <provider>/<model> --agent plan --format json` | Fresh call default; optional `-s <id>` | `--agent plan` |
| `pi` | See [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) | No (fresh call each round) | `--tools read,grep,find,ls` |
For all three CLIs, the preferred execution path is:
For all supported reviewer CLIs, the preferred execution path is:
1. write the reviewer command to a bash script
2. run that script through `reviewer-runtime/run-review.sh`
3. fall back to direct synchronous execution only if the helper is missing or not executable
## Pi Reviewer Support
All workflow variants can use Pi itself as a reviewer CLI. Use `pi/<pi-model-name>` shorthand, for
example `pi/claude-opus-4-7`; this means `REVIEWER_CLI=pi` and `REVIEWER_MODEL=claude-opus-4-7`.
Provider-qualified or multi-slash Pi model IDs are preserved after the first `pi/` prefix, for
example `pi/anthropic/claude-opus-4-7`.
The canonical isolated read-only Pi reviewer flag contract lives in
[PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md). This workflow passes the milestone review
payload at `/tmp/milestone-${REVIEW_ID}.md` and expects the standard `## Summary`,
`## Findings`, and `## Verdict` response. Pi reviewer output is captured as markdown stdout,
not JSON.
If the Pi reviewer model or provider is unavailable, surface the helper stderr/status and use
`pi --list-models [search]` to inspect configured models.
## Notifications
- Telegram is the only supported notification path.
@@ -230,7 +321,7 @@ run-review.sh \
## Variant Hardening Notes
### Claude Code
### Claude Code Hardening
- Must invoke explicit required sub-skills:
- `superpowers:executing-plans`
@@ -238,7 +329,7 @@ run-review.sh \
- `superpowers:verification-before-completion`
- `superpowers:finishing-a-development-branch`
### Codex
### Codex Hardening
- Must use native skill discovery from `~/.agents/skills/` (no CLI wrappers).
- Must verify Superpowers skills symlink: `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
@@ -246,16 +337,17 @@ run-review.sh \
- Must track checklist-driven skills with `update_plan` todos.
- Deprecated CLI commands (`superpowers-codex bootstrap`, `use-skill`) must NOT be used.
### OpenCode
### OpenCode Hardening
- Must use OpenCode native skill tool (not Claude/Codex invocation syntax).
- Must verify Superpowers skill discovery under:
- `~/.agents/skills/superpowers`
- `~/.config/opencode/skills/superpowers`
- Must explicitly load all four execution sub-skills.
### Cursor
### Cursor Hardening
- Must use workspace discovery from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global).
- Must use Cursor-native discovery from `.cursor/skills/`, `~/.cursor/skills/`, or installed Cursor plugin cache entries.
- Must announce skill usage explicitly before invocation.
- Must use `--mode=ask` (read-only) and `--trust` when running reviewer non-interactively.
- Must not use `--force` or `--mode=agent` for review (reviewer should never write files).
+167
View File
@@ -0,0 +1,167 @@
# Skill Manager Installer
Use the skill manager wizard to install, update/reinstall, or remove skills from this repository
for supported local coding clients.
## Quick Start
```bash
./scripts/manage-skills.sh
# or
node scripts/manage-skills.mjs
```
Preview without changing files:
```bash
node scripts/manage-skills.mjs --dry-run
```
Drive the planner non-interactively:
```bash
node scripts/manage-skills.mjs --plan-only --answers answers.json
```
## Supported Clients
The wizard detects these clients by CLI and known skill directories:
| Client | CLI check | Default skill root |
|---|---|---|
| Codex | `codex --version` | `~/.codex/skills` |
| Claude Code | `claude --version` | `~/.claude/skills` |
| Cursor | `cursor-agent --version` then `cursor agent --version` | `.cursor/skills` or `~/.cursor/skills` |
| OpenCode | `opencode --version` | `~/.config/opencode/skills` |
| Pi | `pi --version` | package mode or `.pi/skills` / `~/.pi/agent/skills` |
## Actions
For each selected client/scope, the wizard lists repository skills and offers actions based on installed state:
- missing skill: `install` or `skip`
- installed/stale/unknown skill: `update`, `reinstall`, `remove`, or `skip`
- unsupported variant: reported as `unsupported` and skipped
The current skill matrix is:
| Skill | Codex | Claude Code | Cursor | OpenCode | Pi |
|---|---:|---:|---:|---:|---:|
| `atlassian` | yes | yes | yes | yes | yes |
| `create-plan` | yes | yes | yes | yes | yes |
| `do-task` | yes | yes | yes | yes | yes |
| `implement-plan` | yes | yes | yes | yes | yes |
| `web-automation` | yes | yes | yes | yes | yes |
## Superpowers Handling
Workflow skills require Obra Superpowers:
- `create-plan`: `brainstorming`, `writing-plans`
- `implement-plan`: `executing-plans`, `using-git-worktrees`, `verification-before-completion`, `finishing-a-development-branch`
- `do-task`: `brainstorming`, `test-driven-development`, `verification-before-completion`,
`finishing-a-development-branch` plus conditional `using-git-worktrees`
When a selected install/update needs Superpowers and none are detected for the target client/scope,
the wizard asks whether to install them. It asks for an absolute path to an Obra Superpowers
`skills` directory and lets you choose symlink or copy:
- symlink is recommended because updates to the source checkout are immediately visible
- copy is more self-contained but must be updated manually
The wizard recognizes client-native and plugin-managed Superpowers installs before asking to add another copy:
- Codex: `~/.agents/skills/superpowers` or `~/.codex/superpowers/skills`
- Claude Code: `~/.claude/skills/superpowers` or the enabled `superpowers@claude-plugins-official` plugin cache
- Cursor: `.cursor/skills/superpowers/skills`, `~/.cursor/skills/superpowers/skills`,
or the Cursor public Superpowers plugin cache
- OpenCode: `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`
- Pi: `~/.agents/skills/superpowers`, `~/.pi/agent/skills/superpowers`, or `.pi/skills/superpowers`
When Cursor has no plugin/cache install, the wizard installs Superpowers at
`<scope>/superpowers/skills` because Cursor variants expect
`superpowers/skills/<skill>/SKILL.md`. Symlinking is still preferred when the source tree is
managed locally. Codex and OpenCode can commonly reuse the shared `~/.agents/skills/superpowers`
convention documented in [CODEX.md](./CODEX.md) and [OPENCODE.md](./OPENCODE.md).
When removing the last repository workflow skill from a client/scope, the wizard asks whether to
remove Superpowers for that variant too.
## Reviewer Runtime Helpers
Workflow skills install/update the reviewer-runtime helper bundle automatically when needed:
- non-Pi clients receive `run-review.sh` and `notify-telegram.sh` from `skills/reviewer-runtime/`
- Pi receives `run-review.sh` and `notify-telegram.sh` from `skills/reviewer-runtime/pi/`
- diagnostics tests and nested Pi helper directories are not copied into non-Pi installs
## Pi Package Mode
Pi can be managed as a package install or by manual skill copy. Package mode always manages the
full Pi bundle; per-skill prompts and `--skill` narrowing are ignored for `packageGlobal` and
`packageLocal`.
Package-mode actions:
- `install`: register the package if needed, list bundled skills, and skip already-bootstrapped runtimes.
- `update`: sync the Pi package mirror, reinstall the package registration, and rerun runtime dependency bootstrapping.
- `reinstall`: same behavior as `update`, kept for action parity with manual skill scopes.
- `remove`: unregister the package with `pi remove`; this does not delete repo files or `node_modules`.
Examples:
```bash
node scripts/manage-skills.mjs --client pi --scope packageGlobal --pi-package --action install --yes
node scripts/manage-skills.mjs --client pi --scope packageLocal --pi-package --action install --yes
node scripts/manage-skills.mjs --client pi --scope packageGlobal --pi-package --action update --yes
node scripts/manage-skills.mjs --client pi --scope packageGlobal --pi-package --action remove --yes
```
The compatibility script remains available:
```bash
./scripts/install-pi-package.sh --global
./scripts/install-pi-package.sh --local
```
## Answers JSON
`--plan-only --answers` accepts this shape:
```json
{
"selections": [
{
"clientId": "codex",
"scope": "global",
"actions": {
"create-plan": "install",
"web-automation": "skip"
}
}
]
}
```
The output is JSON containing planned operations, dependency prompts, and final-report rows. No filesystem changes are made.
## Final Report
The final report uses these columns:
| Column | Meaning |
|---|---|
| client | client id |
| scope | selected target scope |
| skill/helper | skill name or helper bundle |
| action | install, update, reinstall, remove, sync, bootstrap, etc. |
| status | `ok`, `skipped`, `failed`, or `warning` |
| details | target path or error detail |
Exit code is non-zero if any selected operation fails.
Dangling symlink warnings are surfaced as `warning` rows. For example, if a previously symlinked
Superpowers source has moved or been deleted, the final report keeps the operation non-destructive
and shows the dangling symlink target in `details` so you can repair or remove it deliberately.
See [PI.md](./PI.md) for Pi package layout details and mirror maintenance.
+72
View File
@@ -0,0 +1,72 @@
# OpenCode Manual Install
## Skill Root
OpenCode skills are installed under:
```bash
~/.config/opencode/skills/<skill-name>/
```
Manual install example:
```bash
mkdir -p ~/.config/opencode/skills/implement-plan
cp -R skills/implement-plan/opencode/* ~/.config/opencode/skills/implement-plan/
```
Use `skills/<skill>/opencode/*` for each supported skill.
## Reviewer Runtime
```bash
mkdir -p ~/.config/opencode/skills/reviewer-runtime
cp skills/reviewer-runtime/run-review.sh ~/.config/opencode/skills/reviewer-runtime/
cp skills/reviewer-runtime/notify-telegram.sh ~/.config/opencode/skills/reviewer-runtime/
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
```
## Superpowers
OpenCode can discover Superpowers from the shared agents skill root or the
OpenCode-specific skills root:
```bash
~/.agents/skills/superpowers
~/.config/opencode/skills/superpowers
```
OpenCode's native setup commonly exposes the shared agents root:
```bash
ln -s /absolute/path/to/obra/superpowers/skills ~/.agents/skills/superpowers
```
OpenCode-specific setup is also supported:
```bash
ln -s /absolute/path/to/obra/superpowers/skills ~/.config/opencode/skills/superpowers
```
Verify:
```bash
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
```
## OpenCode Reviewer Notes
OpenCode reviewer calls use the built-in read-oriented plan agent:
```bash
opencode run -m <provider>/<model> --agent plan --format json "review prompt"
```
## Verify
```bash
opencode --version
test -f ~/.config/opencode/skills/implement-plan/SKILL.md
test -x ~/.config/opencode/skills/reviewer-runtime/run-review.sh
```
+121
View File
@@ -0,0 +1,121 @@
# PI COMMON REVIEWER
## Purpose
This document covers the shared reviewer-runtime helpers used by the Pi workflow skills.
It is intentionally separate from [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md). Superpowers are
skill dependencies; reviewer-runtime is helper-script setup.
## Pi As A Reviewer CLI
Pi workflow skills may use `pi` itself as the reviewer CLI, even when the main workflow is already
running in pi. In that case, the reviewer model is configured independently from the running agent
model. This lets the operator run a workflow with one model while asking another pi-configured
model, including provider-qualified model IDs, to review the plan or implementation.
The canonical isolated, read-only reviewer command is:
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files --model "$REVIEWER_MODEL" --tools read,grep,find,ls -p "Read the review payload and return the requested verdict."
```
Workflow docs should link to this section instead of restating the full flag contract. Each
workflow substitutes its own payload path and verdict prompt:
- `create-plan`: `/tmp/plan-${REVIEW_ID}.md`
- `implement-plan`: `/tmp/milestone-${REVIEW_ID}.md`
- `do-task`: `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md`
User-facing shorthand `pi/<pi-model-name>` means `REVIEWER_CLI=pi` and
`REVIEWER_MODEL=<pi-model-name>`. Split only on the first slash when the prefix before that slash
is exactly `pi`; keep the remainder verbatim. Examples:
- `pi/claude-opus-4-7` -> `claude-opus-4-7`
- `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`
- `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`
Pi reviewer calls must stay isolated from the main workflow:
- Use `--no-session` so the reviewer does not continue or persist the workflow session.
- Use `--no-skills --no-prompt-templates --no-extensions --no-context-files` so the reviewer does
not load workflow skills, project context files, or package extensions that could re-enter
`create-plan`, `implement-plan`, or `do-task`.
- Use exactly `--tools read,grep,find,ls` for review.
The pi reviewer command MUST NOT include `write`, `edit`, or `bash`;
the reviewer reads payloads and diffs but never modifies files or runs commands.
If the reviewer subprocess exits non-zero because the provider, credentials, or model ID are
unavailable, surface the captured stderr/status from `run-review.sh`, then ask the user for a
configured reviewer model. Use `pi --list-models [search]` to inspect available configured models
when needed.
Official references:
- `https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent` documents pi providers,
model selection, skills, extensions, and print mode.
- Local `pi --help` for pi `0.70.0` confirms `--model <pattern>` supports `provider/id`,
`--print, -p` runs non-interactively, `--tools, -t <tools>` allowlists tools, and the read-only
example is `pi --tools read,grep,find,ls -p "Review the code in src/"`.
## Required Files
The workflow-heavy Pi skills expect these helper files from `skills/reviewer-runtime/pi/`:
- `run-review.sh`
- `notify-telegram.sh`
Supported install locations:
1. `.pi/skills/reviewer-runtime/pi/`
2. `~/.pi/agent/skills/reviewer-runtime/pi/`
## Verify An Existing Install
Project-local:
```bash
test -x .pi/skills/reviewer-runtime/pi/run-review.sh
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh
```
Global:
```bash
test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh
test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh
```
## Install The Common Reviewer Helpers
Global install:
```bash
mkdir -p ~/.pi/agent/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* ~/.pi/agent/skills/reviewer-runtime/pi/
chmod +x ~/.pi/agent/skills/reviewer-runtime/pi/*.sh
```
Project-local install:
```bash
mkdir -p .pi/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* .pi/skills/reviewer-runtime/pi/
chmod +x .pi/skills/reviewer-runtime/pi/*.sh
```
## Telegram
If you want the workflow skills to send completion messages, configure:
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_CHAT_ID`
The Pi helper uses the same notification behavior documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md).
## What This Doc Does Not Cover
- installing Obra Superpowers
- Pi package layout decisions
Those belong in [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI.md](./PI.md).
+105
View File
@@ -0,0 +1,105 @@
# PI RESEARCH
## Scope
This document records the pi-specific findings that drive the `pi` variants in this repo. It is
intentionally source-backed so later skill work does not rely on memory or assumptions.
Checked on `2026-04-23`.
## Primary Sources
- [pi skills documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md)
- [pi packages documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/packages.md)
- [pi extensions documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)
- [pi settings documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/settings.md)
- [pi coding-agent README](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md)
## Findings
### Skill Discovery
Pi loads skills from multiple places:
- global skill roots: `~/.pi/agent/skills/` and `~/.agents/skills/`
- project skill roots: `.pi/skills/` and `.agents/skills/`
- package resources: `skills/` convention directories or `pi.skills` entries in `package.json`
- settings-provided skill paths
- explicit `--skill <path>` CLI arguments
Important discovery details from the skills docs:
- directories containing `SKILL.md` are discovered recursively
- top-level `.md` files are loaded as skills in pi-native roots like `.pi/skills/`
- per the upstream skills docs, top-level `.md` files are ignored in `.agents/skills/`
Implication for this repo: `skills/<skill>/pi/SKILL.md` fits pi's recursive discovery model
cleanly for source authoring and manual copies, but it is not clean for package-discovered installs
because Pi requires the immediate parent directory of `SKILL.md` to match the skill frontmatter
`name`.
### Package Support
Pi packages are a first-class distribution path. The package docs say pi can load resources through
conventional directories like `skills/`, or through explicit `pi` manifest entries in `package.json`.
Relevant package behaviors:
- `keywords: ["pi-package"]` makes packages show up in the gallery
- `pi.skills` can point at specific skill directories
- `files` allowlists should be used to keep tarballs tight
- installed pi packages can also ship `extensions/`, `prompts/`, and `themes/`
- when pi installs npm or git packages, it runs `npm install`
Implication for this repo: a single repo-level `package.json` is a viable v1 surface for shipping
only the Pi resources, but the package-facing skill directories must be shaped like
`<skill-name>/SKILL.md`. That means the repo should preserve `skills/<family>/pi/` for editing and
expose a separate mirror such as `pi-package/skills/<skill-name>/` to Pi.
### Settings And Path Overrides
Pi settings support `packages`, `extensions`, `skills`, `prompts`, and `themes`. In both
`~/.pi/agent/settings.json` and `.pi/settings.json`, the `skills` array can point to local files
or directories, and the docs explicitly allow absolute paths and `~`.
Implication for this repo:
- pi can consume repo-local resources without publishing a package
- users can point pi at shared roots like `~/.agents/skills`
- workflow skills can document deterministic helper paths and fall back to settings-driven installs when needed
### Extensions
Pi extensions are TypeScript modules that can:
- register custom tools
- register commands
- intercept lifecycle events and tool calls
- add UI interactions and custom components
- persist session state
The coding-agent README explicitly lists advanced behaviors like sub-agents, plan mode, permission
gates, MCP integration, and git checkpointing as extension-capable areas.
Implication for this repo: extensions are a real opportunity for workflow-heavy skills, but they
are optional for the initial skill port. The base skill variants should remain usable without any
extension dependency.
### Cross-Harness Compatibility
The pi skills docs explicitly call out `~/.agents/skills/` as a supported global skill root, and
describe adding Codex or Claude skill directories through settings when needed.
Implication for this repo:
- existing Superpowers installs exposed through `~/.agents/skills/` can already be visible to pi
- pi-specific skill variants still need their own instructions because pi does not share Codex's
`update_plan`, plan-mode, or worktree assumptions
## Decisions Derived From Research
- Use `skills/<skill>/pi/` for all five skill families.
- Package Pi skills through a separate mirror whose immediate directory names match the frontmatter `name` values.
- Publish shared pi guidance in repo docs instead of burying pi assumptions inside each skill.
- Use one repo-level pi package in v1.
- Assess extensions explicitly, but defer them unless they provide clear v1 value beyond documentation and helper scripts.
+113
View File
@@ -0,0 +1,113 @@
# PI SUPERPOWERS
## Purpose
This document is only about making Obra Superpowers visible to Pi.
If you need the shared reviewer helpers (`run-review.sh`, `notify-telegram.sh`), use
[PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) instead.
## What Pi Needs
The workflow-heavy Pi skills depend on Superpowers such as:
- `brainstorming`
- `writing-plans`
- `executing-plans`
- `test-driven-development`
- `verification-before-completion`
- `finishing-a-development-branch`
- `using-git-worktrees`
Pi can discover them from shared roots like `~/.agents/skills/`, Pi-native roots like
`~/.pi/agent/skills/` or `.pi/skills/`, or settings-defined skill directories.
## Verify An Existing Install
If you think Superpowers may already be installed, check the common shared-root setup first:
```bash
test -L ~/.agents/skills/superpowers
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md
```
If those pass, Pi can usually reuse the same install directly.
To verify a Pi-native install instead:
```bash
test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md || test -f .pi/skills/superpowers/brainstorming/SKILL.md
```
## Install Option 1: Reuse A Shared Skills Root
If you already have Superpowers available for another harness, the simplest Pi setup is to expose
that same tree through `~/.agents/skills/`.
Example:
```bash
mkdir -p ~/.agents/skills
ln -s ~/.codex/superpowers/skills ~/.agents/skills/superpowers
```
Re-run the verification checks above after creating the symlink.
## Install Option 2: Pi-Native Symlink Or Copy
If you do not want to use `~/.agents/skills/`, point Pi at a checked-out Superpowers tree directly.
Global Pi install:
```bash
mkdir -p ~/.pi/agent/skills
ln -s /absolute/path/to/obra/superpowers/skills ~/.pi/agent/skills/superpowers
```
Project-local Pi install:
```bash
mkdir -p .pi/skills
ln -s /absolute/path/to/obra/superpowers/skills .pi/skills/superpowers
```
If you prefer a settings-based path instead of a symlink, add it to either `~/.pi/agent/settings.json` or `.pi/settings.json`:
```json
{
"skills": [
"~/.agents/skills",
"/absolute/path/to/obra/superpowers/skills"
]
}
```
## Post-Install Verification
After any install path, verify the specific skills your workflow needs.
Planning-focused check:
```bash
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md || test -f .pi/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md || test -f ~/.pi/agent/skills/superpowers/writing-plans/SKILL.md || test -f .pi/skills/superpowers/writing-plans/SKILL.md
```
Execution-focused check:
```bash
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.pi/agent/skills/superpowers/test-driven-development/SKILL.md || test -f .pi/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md || test -f .pi/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f .pi/skills/superpowers/finishing-a-development-branch/SKILL.md
```
## What This Doc Does Not Cover
- reviewer-runtime helper installation
- Telegram helper installation
- package layout or Pi package installation
Those belong in [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) and [PI.md](./PI.md).
+165
View File
@@ -0,0 +1,165 @@
# PI
## Purpose
This repo treats pi as a first-class target alongside Codex, Claude Code, Cursor, and OpenCode.
The Pi support surface has two layers:
- editable source variants in `skills/<family>/pi/`
- a package-facing mirror in `pi-package/skills/<skill>/`
That split is intentional. Pi requires the immediate parent directory of `SKILL.md` to match the
skill's frontmatter `name`, so the package cannot point directly at `skills/<family>/pi/`.
Related docs:
- [PI-RESEARCH.md](./PI-RESEARCH.md)
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md)
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md)
## Layout
### Source Of Truth
Edit the **canonical sources** under:
- `skills/atlassian/_source/pi/SKILL.md`
- `skills/create-plan/_source/pi/` (SKILL.md + templates)
- `skills/do-task/_source/pi/` (SKILL.md + templates)
- `skills/implement-plan/_source/pi/SKILL.md`
- `skills/web-automation/_source/pi/SKILL.md`
After editing a canonical source, run `pnpm run sync:pi` to regenerate all
agent-variant directories including `skills/<family>/pi/` and the package mirrors.
Do not edit the generated `skills/<family>/pi/` directories directly — they are
overwritten by the generator.
### Package Mirror
The package exposes:
- `pi-package/skills/atlassian/`
- `pi-package/skills/create-plan/`
- `pi-package/skills/do-task/`
- `pi-package/skills/implement-plan/`
- `pi-package/skills/web-automation/`
Those directories are generated from the canonical sources by
[`scripts/generate-skills.mjs`](../scripts/generate-skills.mjs)
(run via `pnpm run sync:pi`).
### Shared Setup Docs
Workflow-heavy Pi skills split their shared setup across two docs:
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) for installing and verifying Obra Superpowers in Pi
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) for installing and verifying the shared reviewer-runtime helpers
## Package Install
The multi-client skill manager can guide Pi install/update/remove operations alongside the other supported clients:
```bash
./scripts/manage-skills.sh
node scripts/manage-skills.mjs --dry-run
```
The compatibility Pi package installer remains available for the focused Pi package path.
The user-facing Pi package install flow is the repo-owned installer script, not a raw `pi install` command.
Global install from a cloned checkout:
```bash
./scripts/install-pi-package.sh --global
```
Project-local install from a cloned checkout:
```bash
./scripts/install-pi-package.sh --local
```
Prerequisites for that one-liner:
- Node.js 20+
- `pi`
- either `pnpm` on `PATH`, or Node's bundled `corepack` support
The installer uses `pnpm` directly when available and falls back to `corepack pnpm` otherwise.
The root `package.json` pins the pnpm version so Corepack-backed installs resolve consistently.
That script:
- runs `pi install` in the chosen scope
- installs the nested runtime dependencies for `atlassian`
- installs the nested runtime dependencies for `web-automation`
- fetches the CloakBrowser binary for `web-automation`
- prints `pi list` at the end so the active install is visible immediately
For this cloned-checkout flow,
local checkout package install keeps the runtime in `pi-package/skills/<skill>/scripts`.
Pi loads the skills from the package mirror in this repo;
it does not copy them into `~/.pi/agent/skills/<skill>/` or `.pi/skills/<skill>/`
unless you do a manual copy install.
The package surface intentionally ships:
- `pi-package/skills/**`
- `skills/reviewer-runtime/pi/**`
- `docs/PI*.md`
- `scripts/install-pi-package.sh`
- `scripts/manage-skills.mjs` and `scripts/manage-skills.sh`
- `scripts/generate-skills.mjs`
- `scripts/verify-pi-resources.sh`
- `scripts/verify-pi-workflows.sh`
- `scripts/verify-reviewer-support.sh`
It intentionally does not ship `skills/<family>/pi/**` as package-discovered skills.
## Single-Skill Copy Install
If you only want one Pi skill without installing the whole package, copy from the package-facing
mirror into a Pi skill root:
```bash
mkdir -p .pi/skills/create-plan
cp -R pi-package/skills/create-plan/* .pi/skills/create-plan/
```
Global installs use `~/.pi/agent/skills/<skill>/` instead of `.pi/skills/<skill>/`.
## Maintenance Workflow
When a source Pi variant changes:
```bash
pnpm run sync:pi
npm run verify:pi
npm run verify:reviewers
npm pack --dry-run --json
```
The focused `scripts/install-pi-package.sh` installer intentionally does not run sync. It assumes
the checked-in `pi-package/skills/*` mirror is already current. The multi-client skill manager
plans a sync step before manual Pi skill-copy operations so manual installs use the current
package-facing mirror.
The verifier is responsible for catching:
- missing mirror directories
- source/mirror drift
- package metadata pointing at the wrong skill roots
- missing shared Pi docs
- missing user-facing installer wiring
## Extension Decision
Pi extensions are still optional for this repo.
Current v1 decision:
- ship usable Pi skills without extensions
- keep the package focused on skills, docs, and helpers
- revisit extensions only when documentation and helper scripts stop being enough
+65 -8
View File
@@ -1,15 +1,72 @@
# Skills Documentation
This directory contains user-facing docs for each skill.
This directory contains user-facing docs for all skills, agents, and tooling
in the `ai-coding-skills` repository.
## Index
## Reading Order
- [ATLASSIAN.md](./ATLASSIAN.md) — Includes requirements, generated bundle sync, install, auth, safety rules, and usage examples for the Atlassian skill.
- [CREATE-PLAN.md](./CREATE-PLAN.md) — Includes requirements, install, verification, and execution workflow for create-plan.
- [DO-TASK.md](./DO-TASK.md) — Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit. Sibling of create-plan/implement-plan scoped to ad-hoc tasks.
- [IMPLEMENT-PLAN.md](./IMPLEMENT-PLAN.md) — Includes requirements, install, verification, and milestone review workflow for implement-plan.
- [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) — Shared Telegram notification setup used by reviewer-driven skills.
- [WEB-AUTOMATION.md](./WEB-AUTOMATION.md) — Includes requirements, install, dependency verification, and usage examples for web-automation.
Work through the sections below in order on a first read. Return to individual
sections as needed.
### 1. Getting Started
- [INSTALLER.md](./INSTALLER.md) — Guided install/update/remove wizard
(`./scripts/manage-skills.sh`). **Start here** if you want the fastest path
to installed skills.
### 2. Per-Agent Manual Install
Use these guides to install specific skills manually for a single agent:
- [CODEX.md](./CODEX.md) — Manual install for Codex variants.
- [CLAUDE-CODE.md](./CLAUDE-CODE.md) — Manual install for Claude Code variants.
- [CURSOR.md](./CURSOR.md) — Manual install for Cursor variants.
- [OPENCODE.md](./OPENCODE.md) — Manual install for OpenCode variants.
- [PI.md](./PI.md) — Pi overview, source-vs-package layout, and manual install.
### 3. Skill Guides
Reference docs for each skill family:
- [ATLASSIAN.md](./ATLASSIAN.md) — Jira/Confluence CLI: requirements, generated
bundle sync, install, auth, safety rules, and usage examples.
- [CREATE-PLAN.md](./CREATE-PLAN.md) — Structured planning with milestones,
cross-model review, and reviewer CLI requirements.
- [DO-TASK.md](./DO-TASK.md) — Single-prompt end-to-end execution with dual
reviewer loops, TDD-first, single task commit.
- [IMPLEMENT-PLAN.md](./IMPLEMENT-PLAN.md) — Worktree-isolated plan execution
with iterative cross-model milestone review.
- [WEB-AUTOMATION.md](./WEB-AUTOMATION.md) — CloakBrowser-backed browsing,
scraping, auth, and flow automation.
### 4. Pi-Specific Docs
- [PI-RESEARCH.md](./PI-RESEARCH.md) — Source-backed Pi findings that inform
repo Pi variants and packaging choices.
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) — How to install or verify Obra
Superpowers for Pi.
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) — Shared reviewer-runtime
helpers for Pi workflow skills; canonical Pi reviewer flag contract.
### 5. Telegram Notifications
- [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) — Shared Telegram
notification setup for Codex, Claude Code, OpenCode, Cursor, and Pi.
### 6. Reviewer Matrix
- [REVIEWERS.md](./REVIEWERS.md) — Canonical reviewer CLI support matrix;
single source of truth for all workflow variants.
### 7. Development
- [DEVELOPMENT.md](./DEVELOPMENT.md) — Prerequisites, `pnpm run check`,
workspace policy, generation workflow, cross-platform shell support, and the
quality contract.
- [CLEANUP-BASELINE.md](./CLEANUP-BASELINE.md) — As-is quality baseline
captured at M1, updated through subsequent milestones.
- [../CHANGELOG.md](../CHANGELOG.md) — Milestone-by-milestone change record
(includes M3 package-metadata rename table).
## Repo Setup
+81
View File
@@ -0,0 +1,81 @@
# Reviewer CLI Support Matrix
This document is the **single canonical source** for the reviewer CLI support
matrix used by `create-plan`, `implement-plan`, and `do-task`. All workflow
docs and `SKILL.md` variants must refer here and stay in sync.
## Canonical Reviewer CLIs
| CLI | Install | Verify | Read-Only Mode | Session Resume |
|-----|---------|--------|----------------|----------------|
| `codex` | `npm install -g @openai/codex` | `codex --version` | `-s read-only` | Yes (`codex exec resume <id>`) |
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --version` | `--strict-mcp-config --setting-sources user` | No (fresh call each round) |
| `cursor` | `curl https://cursor.com/install -fsS \| bash` | `cursor-agent --version` | `--mode=ask` | Yes (`--resume <id>`) |
| `opencode` | `brew install opencode` or your package manager | `opencode --version` | `--agent plan` | Opt-in (`-s <id>`; fresh call is the default) |
| `pi` | Install Pi coding agent | `pi --version`; list models with `pi --list-models [search]` | `--tools read,grep,find,ls` | No (fresh call each round) |
**`skip`** is always a valid value; it bypasses the reviewer loop and requires
explicit user approval instead.
## Notes
- The reviewer CLI is independent of which agent is running the skill. For
example, Claude Code can send plans to Codex for review and vice versa.
- **`cursor` reviewer prerequisite:** `jq` is required to parse Cursor's JSON
output. Install via `brew install jq` (macOS) or your package manager.
Verify: `jq --version`. The Cursor variant of `do-task` makes `jq` a hard
prerequisite regardless of which reviewer CLI is selected.
- **`opencode` reviewer:** Uses `--agent plan` (built-in read-oriented agent)
for review posture. Session resume is opt-in via `-s <id>`; fresh call is the
recommended default for non-interactive headless runs.
- **`pi` reviewer:** Use `pi/<pi-model-name>` shorthand, for example
`pi/claude-opus-4-7`; this means `REVIEWER_CLI=pi` and
`REVIEWER_MODEL=claude-opus-4-7`. Provider-qualified or multi-slash Pi model
IDs are preserved after the first `pi/` prefix, for example
`pi/anthropic/claude-opus-4-7`. The canonical isolated read-only Pi reviewer
flag contract lives in [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md).
## Reviewer Output Contract
All reviewer CLIs must return the following structure:
```markdown
## Summary
...
## Findings
### P0
- ...
### P1
- ...
### P2
- ...
### P3
- ...
## Verdict
VERDICT: APPROVED
```
Severity levels:
| Level | Meaning |
|-------|---------|
| `P0` | Total blocker |
| `P1` | Major risk |
| `P2` | Must-fix before approval |
| `P3` | Cosmetic / nice to have (non-blocking) |
Rules:
- `VERDICT: APPROVED` is valid only when no `P0`, `P1`, or `P2` findings remain.
- `P3` findings are non-blocking, but the caller should still try to fix them
when cheap and safe.
- Each severity section uses `- None.` when empty.
## References
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) — Canonical Pi reviewer
flag contract and read-only command template.
- [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) — Telegram
notification setup used by reviewer-driven workflows.
+77 -13
View File
@@ -2,7 +2,9 @@
## Purpose
Shared setup for Telegram notifications used by reviewer-driven skills such as `create-plan` and `implement-plan`, both for completion and for pauses that need user attention.
Shared setup for Telegram notifications used by reviewer-driven skills such as
`create-plan`, `implement-plan`, and `do-task`, both for completion and for
pauses that need user attention.
## Requirements
@@ -12,31 +14,44 @@ Shared setup for Telegram notifications used by reviewer-driven skills such as `
- Codex: `~/.codex/skills/reviewer-runtime/notify-telegram.sh`
- Claude Code: `~/.claude/skills/reviewer-runtime/notify-telegram.sh`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/notify-telegram.sh`
- Cursor: `.cursor/skills/reviewer-runtime/notify-telegram.sh` or `~/.cursor/skills/reviewer-runtime/notify-telegram.sh`
- Cursor: `.cursor/skills/reviewer-runtime/notify-telegram.sh`
or `~/.cursor/skills/reviewer-runtime/notify-telegram.sh`
- Pi: `.pi/skills/reviewer-runtime/pi/notify-telegram.sh`
or `~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh`
## Install
The helper ships from `skills/reviewer-runtime/` together with `run-review.sh`.
The helpers ship from `skills/reviewer-runtime/` (non-Pi) and
`skills/reviewer-runtime/pi/` (Pi) together with `run-review.sh`.
### Codex
```bash
mkdir -p ~/.codex/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh \
skills/reviewer-runtime/notify-telegram.sh \
~/.codex/skills/reviewer-runtime/
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
```
### Claude Code
```bash
mkdir -p ~/.claude/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh \
skills/reviewer-runtime/notify-telegram.sh \
~/.claude/skills/reviewer-runtime/
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
```
### OpenCode
```bash
mkdir -p ~/.config/opencode/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.config/opencode/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh \
skills/reviewer-runtime/notify-telegram.sh \
~/.config/opencode/skills/reviewer-runtime/
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
```
### Cursor
@@ -45,28 +60,67 @@ Repo-local install:
```bash
mkdir -p .cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* .cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh \
skills/reviewer-runtime/notify-telegram.sh \
.cursor/skills/reviewer-runtime/
chmod +x .cursor/skills/reviewer-runtime/*.sh
```
Global install:
```bash
mkdir -p ~/.cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/run-review.sh \
skills/reviewer-runtime/notify-telegram.sh \
~/.cursor/skills/reviewer-runtime/
chmod +x ~/.cursor/skills/reviewer-runtime/*.sh
```
### Pi
Pi uses a separate set of helpers from `skills/reviewer-runtime/pi/` that are
optimized for the Pi agent environment. See
[PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) for full details.
Global install:
```bash
mkdir -p ~/.pi/agent/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* ~/.pi/agent/skills/reviewer-runtime/pi/
chmod +x ~/.pi/agent/skills/reviewer-runtime/pi/*.sh
```
Repo-local install:
```bash
mkdir -p .pi/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* .pi/skills/reviewer-runtime/pi/
chmod +x .pi/skills/reviewer-runtime/pi/*.sh
```
## Verify Installation
### Verify: Non-Pi agents
```bash
test -x ~/.codex/skills/reviewer-runtime/notify-telegram.sh || true
test -x ~/.claude/skills/reviewer-runtime/notify-telegram.sh || true
test -x ~/.config/opencode/skills/reviewer-runtime/notify-telegram.sh || true
test -x .cursor/skills/reviewer-runtime/notify-telegram.sh || test -x ~/.cursor/skills/reviewer-runtime/notify-telegram.sh || true
test -x .cursor/skills/reviewer-runtime/notify-telegram.sh \
|| test -x ~/.cursor/skills/reviewer-runtime/notify-telegram.sh || true
```
### Verify: Pi
```bash
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh \
|| test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh || true
```
## Configure Telegram
Export the required variables before running a skill that sends Telegram notifications:
Export the required variables before running a skill that sends Telegram
notifications:
```bash
export TELEGRAM_BOT_TOKEN="<bot-token>"
@@ -81,7 +135,7 @@ export TELEGRAM_API_BASE_URL="https://api.telegram.org"
## Test the Helper
Example:
Non-Pi agents example:
```bash
TELEGRAM_BOT_TOKEN="<bot-token>" \
@@ -89,9 +143,19 @@ TELEGRAM_CHAT_ID="<chat-id>" \
skills/reviewer-runtime/notify-telegram.sh --message "Telegram notification test"
```
Pi example:
```bash
TELEGRAM_BOT_TOKEN="<bot-token>" \
TELEGRAM_CHAT_ID="<chat-id>" \
skills/reviewer-runtime/pi/notify-telegram.sh --message "Pi Telegram test"
```
## Rules
- Telegram is the only supported notification path for these skills.
- Notification failures are non-blocking, but they must be surfaced to the user.
- Before stopping for any user interaction, approval, or manual decision, send a Telegram summary first if configured.
- Skills should report when Telegram is not configured instead of silently pretending a notification was sent.
- Before stopping for any user interaction, approval, or manual decision, send a
Telegram summary first if configured.
- Skills should report when Telegram is not configured instead of silently
pretending a notification was sent.
+45 -2
View File
@@ -48,6 +48,22 @@ pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
### Cursor
Repo-local install:
```bash
mkdir -p .cursor/skills/web-automation
cp -R skills/web-automation/cursor/* .cursor/skills/web-automation/
cd .cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global installs use `~/.cursor/skills/web-automation/` instead.
### OpenCode
```bash
@@ -60,6 +76,31 @@ pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
pnpm run sync:pi
mkdir -p .pi/skills/web-automation
cp -R pi-package/skills/web-automation/* .pi/skills/web-automation/
cd .pi/skills/web-automation/scripts
pnpm install --frozen-lockfile
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global manual installs use `~/.pi/agent/skills/web-automation/` instead of `.pi/skills/web-automation/`.
## Update To The Latest CloakBrowser
Run inside the installed `scripts/` directory for the variant you are using:
@@ -71,7 +112,8 @@ pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
This repo intentionally treats `cloakbrowser` as a refreshable dependency: update to the latest available compatible release, then regenerate the lockfile from that resolved set.
This repo intentionally treats `cloakbrowser` as a refreshable dependency: update to the latest
available compatible release, then regenerate the lockfile from that resolved set.
## Verify Installation & Wiring
@@ -88,7 +130,8 @@ Expected checks:
If the check fails, stop and return:
"Missing dependency/config: web-automation requires `cloakbrowser` and `playwright-core` with CloakBrowser-based scripts. Run setup in this skill, then retry."
"Missing dependency/config: web-automation requires `cloakbrowser` and `playwright-core` with
CloakBrowser-based scripts. Run setup in this skill, then retry."
If runtime later fails with native-binding issues, run:
+54
View File
@@ -0,0 +1,54 @@
// ESLint flat config — repo root (M1)
// Scoped to root-level scripts only. Nested workspace packages manage
// their own lint config (or inherit this in a later milestone).
import js from "@eslint/js";
import globals from "globals";
export default [
// Apply ESLint recommended rules to root-level JS/MJS scripts
{
files: ["scripts/**/*.mjs", "scripts/**/*.js"],
...js.configs.recommended,
languageOptions: {
globals: {
...globals.node,
},
},
},
// Global ignores — never lint generated directories or node_modules
{
ignores: [
"**/node_modules/**",
// Generated agent-variant directories (M3: now uniquely named but still
// not linted — the canonical source in shared/ or _source/ is the linting target)
"skills/atlassian/codex/**",
"skills/atlassian/claude-code/**",
"skills/atlassian/cursor/**",
"skills/atlassian/opencode/**",
"skills/atlassian/pi/**",
"skills/web-automation/claude-code/**",
"skills/web-automation/cursor/**",
"skills/web-automation/opencode/**",
"skills/web-automation/pi/**",
// skill-only generated variants (no scripts to lint)
"skills/create-plan/claude-code/**",
"skills/create-plan/codex/**",
"skills/create-plan/cursor/**",
"skills/create-plan/opencode/**",
"skills/create-plan/pi/**",
"skills/do-task/claude-code/**",
"skills/do-task/codex/**",
"skills/do-task/cursor/**",
"skills/do-task/opencode/**",
"skills/do-task/pi/**",
"skills/implement-plan/claude-code/**",
"skills/implement-plan/codex/**",
"skills/implement-plan/cursor/**",
"skills/implement-plan/opencode/**",
"skills/implement-plan/pi/**",
"skills/reviewer-runtime/pi/**",
"pi-package/**",
],
},
];
+9
View File
@@ -0,0 +1,9 @@
{
"timeout": "10s",
"retryCount": 2,
"retryOn429": true,
"aliveStatusCodes": [200, 206, 429],
"ignorePatterns": [
{ "pattern": "^https?://" }
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"timeout": "10s",
"retryCount": 2,
"retryOn429": true,
"aliveStatusCodes": [200, 206, 429],
"ignorePatterns": [
{ "pattern": "^https://github.com/obra/" },
{ "pattern": "^https://github.com/anthropics/" },
{ "pattern": "^https://localhost" },
{ "pattern": "^http://localhost" }
]
}
+77
View File
@@ -0,0 +1,77 @@
{
"name": "ai-coding-skills-pi",
"version": "0.1.0",
"description": "Pi variants and shared runtime helpers for ai-coding-skills.",
"license": "UNLICENSED",
"private": true,
"keywords": [
"pi-package",
"agent-skills",
"pi"
],
"files": [
"README.md",
"docs/ATLASSIAN.md",
"docs/CLAUDE-CODE.md",
"docs/CODEX.md",
"docs/CREATE-PLAN.md",
"docs/CURSOR.md",
"docs/DO-TASK.md",
"docs/IMPLEMENT-PLAN.md",
"docs/INSTALLER.md",
"docs/OPENCODE.md",
"docs/README.md",
"docs/TELEGRAM-NOTIFICATIONS.md",
"docs/PI.md",
"docs/PI-RESEARCH.md",
"docs/PI-SUPERPOWERS.md",
"docs/PI-COMMON-REVIEWER.md",
"docs/WEB-AUTOMATION.md",
"docs/DEVELOPMENT.md",
"docs/REVIEWERS.md",
"pi-package/skills",
"skills/reviewer-runtime/pi",
"scripts/install-pi-package.sh",
"scripts/lib/skill-manager-core.mjs",
"scripts/manage-skills.mjs",
"scripts/manage-skills.sh",
"CHANGELOG.md",
"scripts/generate-skills.mjs",
"scripts/verify-pi-resources.sh",
"scripts/verify-pi-workflows.sh",
"scripts/verify-reviewer-support.sh"
],
"scripts": {
"sync:pi": "node scripts/generate-skills.mjs",
"verify:pi": "./scripts/verify-pi-resources.sh && ./scripts/verify-pi-workflows.sh",
"verify:reviewers": "./scripts/verify-reviewer-support.sh",
"test:installer": "node --test scripts/tests/*.test.mjs",
"lint": "eslint . ; R1=$?; node scripts/lib/run-shellcheck.mjs ; R2=$?; [ $((R1+R2)) -eq 0 ]",
"lint:fix": "eslint . --fix && prettier --write .",
"typecheck": "pnpm run -r --if-present typecheck",
"test": "pnpm run test:installer && pnpm run -r --if-present test",
"verify:docs": "markdownlint-cli2 ; R1=$?; node scripts/lib/run-link-check.mjs ; R2=$?; node scripts/verify-docs-flow.mjs ; R3=$?; [ $((R1+R2+R3)) -eq 0 ]",
"verify:docs:online": "markdownlint-cli2 ; R1=$?; node scripts/lib/run-link-check.mjs --online ; R2=$?; node scripts/verify-docs-flow.mjs ; R3=$?; [ $((R1+R2+R3)) -eq 0 ]",
"verify:generated": "node scripts/verify-generated.mjs",
"verify:ci": "node scripts/lib/assert-no-pnpm-version-pin.mjs",
"check": "node scripts/lib/run-check.mjs"
},
"pi": {
"skills": [
"./pi-package/skills/atlassian",
"./pi-package/skills/create-plan",
"./pi-package/skills/do-task",
"./pi-package/skills/implement-plan",
"./pi-package/skills/web-automation"
]
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"devDependencies": {
"@eslint/js": "10.0.1",
"eslint": "10.3.0",
"globals": "17.6.0",
"markdown-link-check": "3.14.2",
"markdownlint-cli2": "0.22.1",
"prettier": "3.8.3"
}
}
@@ -0,0 +1,103 @@
{
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
"generator": "scripts/generate-skills.mjs",
"generatedRoot": "pi-package/skills/atlassian",
"files": [
{
"path": "scripts/package.json",
"kind": "file",
"mode": "644",
"sha256": "97dd269f922ea83f6abfd29497ca532b94540acd1461b3a012f92853db55e196"
},
{
"path": "scripts/pnpm-lock.yaml",
"kind": "file",
"mode": "644",
"sha256": "15556a6f53e68bb8d92d2710aae0836bc80af7f29be9d63aa1b87fcbd33732c6"
},
{
"path": "scripts/src/adf.ts",
"kind": "file",
"mode": "644",
"sha256": "c7c3b4a78ccd8fb5a8ab99c82e0eab67a0a0d656b3985c1f56817bda199ad20f"
},
{
"path": "scripts/src/cli.ts",
"kind": "file",
"mode": "644",
"sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff"
},
{
"path": "scripts/src/command-helpers.ts",
"kind": "file",
"mode": "644",
"sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7"
},
{
"path": "scripts/src/config.ts",
"kind": "file",
"mode": "644",
"sha256": "700dcdce96afab5294426e09f539135ae5432632370260190d6292071422eb3f"
},
{
"path": "scripts/src/confluence.ts",
"kind": "file",
"mode": "644",
"sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095"
},
{
"path": "scripts/src/files.ts",
"kind": "file",
"mode": "644",
"sha256": "16296eaa3ae41a4d7c694773036f9bb4bd2baa2db6a9c318078532b713678dba"
},
{
"path": "scripts/src/health.ts",
"kind": "file",
"mode": "644",
"sha256": "1db4b49e05b16a095b7e7ca31cdc4e22ebda19e20e05c40baaaac648eaec0d08"
},
{
"path": "scripts/src/http.ts",
"kind": "file",
"mode": "644",
"sha256": "66444b777d4d9b14d9793eb051c586eb811d2b36815b1018dd9d7517666c7eb2"
},
{
"path": "scripts/src/jira.ts",
"kind": "file",
"mode": "644",
"sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07"
},
{
"path": "scripts/src/output.ts",
"kind": "file",
"mode": "644",
"sha256": "38e99818582a4962c09a83175634cba2bfead6acf33bd5f43cdca5caed7100a0"
},
{
"path": "scripts/src/raw.ts",
"kind": "file",
"mode": "644",
"sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670"
},
{
"path": "scripts/src/types.ts",
"kind": "file",
"mode": "644",
"sha256": "9f92d27ab68604d5abfd0f5dc9552b96fed6d1f9fc7dc6eb30190d8b617628bf"
},
{
"path": "scripts/tsconfig.json",
"kind": "file",
"mode": "644",
"sha256": "3c2eb7ba5c95a16cada153de4787ca7a4bf179609bf3848e12ff15b1b7927a68"
},
{
"path": "SKILL.md",
"kind": "file",
"mode": "644",
"sha256": "69d83441799f3feada7fbf85691bda16fc30718b724871d7e37cfac574db2253"
}
]
}
+101
View File
@@ -0,0 +1,101 @@
---
name: atlassian
description: Interact with Atlassian Cloud Jira and Confluence through a portable task-oriented CLI for search, issue/page edits, comments, transitions, and bounded raw requests.
---
<!-- ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/_source/pi/SKILL.md and run `pnpm run sync:pi`. -->
# Atlassian (Pi)
Portable Atlassian workflows for pi using the shared TypeScript CLI in `scripts/`.
## Requirements
- Node.js 20+
- `pnpm`
- Atlassian Cloud account access
- `ATLASSIAN_BASE_URL`
- `ATLASSIAN_EMAIL`
- `ATLASSIAN_API_TOKEN`
The `ATLASSIAN_*` values may come from the shell environment or a `.env` file in the installed skill's `scripts/` directory.
## First-Time Setup
Global install:
```bash
mkdir -p ~/.pi/agent/skills/atlassian
cp -R skills/atlassian/pi/* ~/.pi/agent/skills/atlassian/
cd ~/.pi/agent/skills/atlassian/scripts
pnpm install
```
Project-local install:
```bash
mkdir -p .pi/skills/atlassian
cp -R skills/atlassian/pi/* .pi/skills/atlassian/
cd .pi/skills/atlassian/scripts
pnpm install
```
Pi can also load this repo through settings or package installs as documented in [docs/PI.md](../../../docs/PI.md).
If you installed this repo from a local checkout with `./scripts/install-pi-package.sh`, the runtime stays in the checkout mirror at `pi-package/skills/atlassian/scripts`.
## Prerequisite Check (MANDATORY)
Run inside the skill runtime directory that matches your install style:
- local checkout package install: `pi-package/skills/atlassian/scripts`
- project-local copied install: `.pi/skills/atlassian/scripts`
- global copied install: `~/.pi/agent/skills/atlassian/scripts`
```bash
cd pi-package/skills/atlassian/scripts
node -e "require.resolve('commander');require.resolve('dotenv');console.log('OK: runtime dependencies installed')"
node -e 'require("dotenv").config({ path: ".env" }); const required = ["ATLASSIAN_BASE_URL", "ATLASSIAN_EMAIL", "ATLASSIAN_API_TOKEN"]; const missing = required.filter((key) => !(process.env[key] || "").trim()); if (missing.length) { console.error("Missing required Atlassian config: " + missing.join(", ")); process.exit(1); } console.log("OK: Atlassian config present")'
pnpm atlassian health
```
If any check fails, stop and return:
`Missing dependency/config: atlassian requires installed CLI dependencies and valid Atlassian Cloud credentials. Configure ATLASSIAN_* in the shell environment or scripts/.env, then retry.`
## Supported Commands
- `pnpm atlassian health`
- `pnpm atlassian jira-search --jql "..."`
- `pnpm atlassian jira-get --issue ABC-123`
- `pnpm atlassian jira-create ... [--dry-run]`
- `pnpm atlassian jira-update ... [--dry-run]`
- `pnpm atlassian jira-comment ... [--dry-run]`
- `pnpm atlassian jira-transitions --issue ABC-123`
- `pnpm atlassian jira-transition ... [--dry-run]`
- `pnpm atlassian conf-search --query "..."`
- `pnpm atlassian conf-get --page 12345`
- `pnpm atlassian conf-create ... [--dry-run]`
- `pnpm atlassian conf-update ... [--dry-run]`
- `pnpm atlassian conf-comment ... [--dry-run]`
- `pnpm atlassian conf-children --page 12345`
- `pnpm atlassian raw --product jira|confluence --method GET|POST|PUT --path ...`
## Usage Examples
- `pnpm atlassian jira-search --jql "project = ENG ORDER BY updated DESC" --max-results 10`
- `pnpm atlassian conf-comment --page 12345 --body-file comment.storage.html --dry-run`
- `pnpm atlassian raw --product jira --method GET --path "/rest/api/3/issue/ENG-123"`
## Safety Rules
- Default output is JSON; prefer that for agent workflows.
- Use `--dry-run` before any mutating command unless the user clearly wants the write to happen immediately.
- `raw` is for explicit edge cases only and does not allow `DELETE`.
- `--body-file` must stay inside the current workspace.
- Confluence write bodies should be storage-format inputs in v1.
## Notes
- Atlassian Cloud is the primary supported platform in v1.
- Package installs use the repo's `pi-package/skills/atlassian/` mirror so the installed skill directory name matches `atlassian`.
@@ -0,0 +1,21 @@
{
"name": "@ai-coding-skills/atlassian-pi-mirror",
"version": "1.0.0",
"description": "Shared runtime for the Atlassian skill",
"type": "module",
"scripts": {
"atlassian": "tsx src/cli.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"commander": "^13.1.0",
"dotenv": "^16.4.7"
},
"devDependencies": {
"@types/node": "^24.3.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"private": true
}
+361
View File
@@ -0,0 +1,361 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
commander:
specifier: ^13.1.0
version: 13.1.0
dotenv:
specifier: ^16.4.7
version: 16.6.1
devDependencies:
'@types/node':
specifier: ^24.3.0
version: 24.12.0
tsx:
specifier: ^4.20.5
version: 4.21.0
typescript:
specifier: ^5.9.2
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/node@24.12.0':
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
snapshots:
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@types/node@24.12.0':
dependencies:
undici-types: 7.16.0
commander@13.1.0: {}
dotenv@16.6.1: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
resolve-pkg-maps@1.0.0: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.3
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}
undici-types@7.16.0: {}
@@ -0,0 +1,93 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
const TEXT_NODE = "text";
function textNode(text: string) {
return {
type: TEXT_NODE,
text,
};
}
function paragraphNode(lines: string[]) {
const content: Array<{ type: string; text?: string }> = [];
lines.forEach((line, index) => {
if (index > 0) {
content.push({ type: "hardBreak" });
}
if (line.length > 0) {
content.push(textNode(line));
}
});
return {
type: "paragraph",
...(content.length > 0 ? { content } : {}),
};
}
export function markdownToAdf(input: string) {
const lines = input.replace(/\r\n/g, "\n").split("\n");
const content: Array<Record<string, unknown>> = [];
let index = 0;
while (index < lines.length) {
const current = lines[index]?.trimEnd() ?? "";
if (current.trim().length === 0) {
index += 1;
continue;
}
const heading = current.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
content.push({
type: "heading",
attrs: { level: heading[1].length },
content: [textNode(heading[2])],
});
index += 1;
continue;
}
if (/^[-*]\s+/.test(current)) {
const items: Array<Record<string, unknown>> = [];
while (index < lines.length && /^[-*]\s+/.test(lines[index] ?? "")) {
items.push({
type: "listItem",
content: [
{
type: "paragraph",
content: [textNode((lines[index] ?? "").replace(/^[-*]\s+/, ""))],
},
],
});
index += 1;
}
content.push({
type: "bulletList",
content: items,
});
continue;
}
const paragraphLines: string[] = [];
while (index < lines.length && (lines[index]?.trim().length ?? 0) > 0) {
paragraphLines.push(lines[index] ?? "");
index += 1;
}
content.push(paragraphNode(paragraphLines));
}
return {
type: "doc",
version: 1,
content,
};
}
@@ -0,0 +1,330 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import process from "node:process";
import { pathToFileURL } from "node:url";
import { Command } from "commander";
import { resolveFormat } from "./command-helpers.js";
import { createConfluenceClient } from "./confluence.js";
import { loadConfig } from "./config.js";
import { readWorkspaceFile } from "./files.js";
import { runHealthCheck } from "./health.js";
import { createJiraClient } from "./jira.js";
import { writeOutput } from "./output.js";
import { runRawCommand } from "./raw.js";
import type { FetchLike, Writer } from "./types.js";
type CliContext = {
cwd?: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: FetchLike;
stdout?: Writer;
stderr?: Writer;
};
function createRuntime(context: CliContext) {
const cwd = context.cwd ?? process.cwd();
const env = context.env ?? process.env;
const stdout = context.stdout ?? process.stdout;
const stderr = context.stderr ?? process.stderr;
let configCache: ReturnType<typeof loadConfig> | undefined;
let jiraCache: ReturnType<typeof createJiraClient> | undefined;
let confluenceCache: ReturnType<typeof createConfluenceClient> | undefined;
function getConfig() {
configCache ??= loadConfig(env, { cwd });
return configCache;
}
function getJiraClient() {
jiraCache ??= createJiraClient({
config: getConfig(),
fetchImpl: context.fetchImpl,
});
return jiraCache;
}
function getConfluenceClient() {
confluenceCache ??= createConfluenceClient({
config: getConfig(),
fetchImpl: context.fetchImpl,
});
return confluenceCache;
}
async function readBodyFile(filePath: string | undefined) {
if (!filePath) {
return undefined;
}
return readWorkspaceFile(filePath, cwd);
}
return {
cwd,
stdout,
stderr,
readBodyFile,
getConfig,
getJiraClient,
getConfluenceClient,
fetchImpl: context.fetchImpl,
};
}
export function buildProgram(context: CliContext = {}) {
const runtime = createRuntime(context);
const program = new Command()
.name("atlassian")
.description("Portable Atlassian CLI for multi-agent skills")
.version("0.1.0");
program
.command("health")
.description("Validate configuration and Atlassian connectivity")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runHealthCheck(runtime.getConfig(), runtime.fetchImpl);
writeOutput(
runtime.stdout,
payload,
resolveFormat(options.format),
);
});
program
.command("conf-search")
.requiredOption("--query <query>", "CQL search query")
.option("--max-results <number>", "Maximum results to return", "50")
.option("--start-at <number>", "Result offset", "0")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getConfluenceClient().searchPages({
query: options.query,
maxResults: Number(options.maxResults),
startAt: Number(options.startAt),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("conf-get")
.requiredOption("--page <page>", "Confluence page ID")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getConfluenceClient().getPage(options.page);
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("conf-create")
.requiredOption("--title <title>", "Confluence page title")
.requiredOption("--body-file <path>", "Workspace-relative storage-format body file")
.option("--space <space>", "Confluence space ID")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getConfluenceClient().createPage({
space: options.space,
title: options.title,
body: (await runtime.readBodyFile(options.bodyFile)) as string,
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("conf-update")
.requiredOption("--page <page>", "Confluence page ID")
.requiredOption("--title <title>", "Confluence page title")
.requiredOption("--body-file <path>", "Workspace-relative storage-format body file")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getConfluenceClient().updatePage({
pageId: options.page,
title: options.title,
body: (await runtime.readBodyFile(options.bodyFile)) as string,
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("conf-comment")
.requiredOption("--page <page>", "Confluence page ID")
.requiredOption("--body-file <path>", "Workspace-relative storage-format body file")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getConfluenceClient().commentPage({
pageId: options.page,
body: (await runtime.readBodyFile(options.bodyFile)) as string,
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("conf-children")
.requiredOption("--page <page>", "Confluence page ID")
.option("--max-results <number>", "Maximum results to return", "50")
.option("--start-at <number>", "Cursor/start token", "0")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getConfluenceClient().listChildren(
options.page,
Number(options.maxResults),
Number(options.startAt),
);
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("raw")
.requiredOption("--product <product>", "jira or confluence")
.requiredOption("--method <method>", "GET, POST, or PUT")
.requiredOption("--path <path>", "Validated API path")
.option("--body-file <path>", "Workspace-relative JSON file")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runRawCommand(runtime.getConfig(), runtime.fetchImpl, {
product: options.product,
method: String(options.method).toUpperCase(),
path: options.path,
bodyFile: options.bodyFile,
cwd: runtime.cwd,
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-search")
.requiredOption("--jql <jql>", "JQL expression to execute")
.option("--max-results <number>", "Maximum results to return", "50")
.option("--start-at <number>", "Result offset", "0")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().searchIssues({
jql: options.jql,
maxResults: Number(options.maxResults),
startAt: Number(options.startAt),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-get")
.requiredOption("--issue <issue>", "Issue key")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().getIssue(options.issue);
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-create")
.requiredOption("--type <type>", "Issue type name")
.requiredOption("--summary <summary>", "Issue summary")
.option("--project <project>", "Project key")
.option("--description-file <path>", "Workspace-relative markdown/text file")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().createIssue({
project: options.project,
type: options.type,
summary: options.summary,
description: await runtime.readBodyFile(options.descriptionFile),
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-update")
.requiredOption("--issue <issue>", "Issue key")
.option("--summary <summary>", "Updated summary")
.option("--description-file <path>", "Workspace-relative markdown/text file")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().updateIssue({
issue: options.issue,
summary: options.summary,
description: await runtime.readBodyFile(options.descriptionFile),
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-comment")
.requiredOption("--issue <issue>", "Issue key")
.requiredOption("--body-file <path>", "Workspace-relative markdown/text file")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().commentIssue({
issue: options.issue,
body: (await runtime.readBodyFile(options.bodyFile)) as string,
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-transitions")
.requiredOption("--issue <issue>", "Issue key")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().getTransitions(options.issue);
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
program
.command("jira-transition")
.requiredOption("--issue <issue>", "Issue key")
.requiredOption("--transition <transition>", "Transition ID")
.option("--dry-run", "Print the request without sending it")
.option("--format <format>", "Output format", "json")
.action(async (options) => {
const payload = await runtime.getJiraClient().transitionIssue({
issue: options.issue,
transition: options.transition,
dryRun: Boolean(options.dryRun),
});
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
});
return program;
}
export async function runCli(argv = process.argv, context: CliContext = {}) {
const program = buildProgram(context);
await program.parseAsync(argv);
}
const isDirectExecution =
Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectExecution) {
runCli().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}
@@ -0,0 +1,25 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import type { CommandOutput, OutputFormat } from "./types.js";
/**
* Produce the standard dry-run response payload for write operations.
*
* Use this when `--dry-run` is passed to skip the actual API call and
* echo the pending request back to the caller.
*
* @example
* if (input.dryRun) return dryRunResponse(request);
*/
export function dryRunResponse<T>(data: T): CommandOutput<T> {
return { ok: true, dryRun: true, data };
}
/**
* Resolve the `--format` CLI option to a typed OutputFormat.
*
* Returns `"text"` only for the exact string `"text"`;
* all other values (including `undefined`) fall back to `"json"`.
*/
export function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
@@ -0,0 +1,53 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import path from "node:path";
import { config as loadDotEnv } from "dotenv";
import type { AtlassianConfig } from "./types.js";
function normalizeBaseUrl(value: string) {
return value.replace(/\/+$/, "");
}
function readRequired(env: NodeJS.ProcessEnv, key: string) {
const value = env[key]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export function loadConfig(
env: NodeJS.ProcessEnv = process.env,
options?: {
cwd?: string;
},
): AtlassianConfig {
loadDotEnv({
path: path.resolve(options?.cwd ?? process.cwd(), ".env"),
processEnv: env as Record<string, string>,
override: false,
});
const baseUrl = normalizeBaseUrl(readRequired(env, "ATLASSIAN_BASE_URL"));
return {
baseUrl,
jiraBaseUrl: normalizeBaseUrl(env.ATLASSIAN_JIRA_BASE_URL?.trim() || baseUrl),
confluenceBaseUrl: normalizeBaseUrl(env.ATLASSIAN_CONFLUENCE_BASE_URL?.trim() || baseUrl),
email: readRequired(env, "ATLASSIAN_EMAIL"),
apiToken: readRequired(env, "ATLASSIAN_API_TOKEN"),
defaultProject: env.ATLASSIAN_DEFAULT_PROJECT?.trim() || undefined,
defaultSpace: env.ATLASSIAN_DEFAULT_SPACE?.trim() || undefined,
};
}
export function createBasicAuthHeader(config: {
email: string;
apiToken: string;
[key: string]: unknown;
}) {
return `Basic ${Buffer.from(`${config.email}:${config.apiToken}`).toString("base64")}`;
}
@@ -0,0 +1,276 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
type ConfluenceClientOptions = {
config: AtlassianConfig;
fetchImpl?: FetchLike;
};
type SearchInput = {
query: string;
maxResults: number;
startAt: number;
};
type CreateInput = {
space?: string;
title: string;
body: string;
dryRun?: boolean;
};
type UpdateInput = {
pageId: string;
title: string;
body: string;
dryRun?: boolean;
};
type CommentInput = {
pageId: string;
body: string;
dryRun?: boolean;
};
type PageSummary = {
id: string;
title: string;
type: string;
status?: string;
spaceId?: string;
url?: string;
};
function buildUrl(baseUrl: string, path: string) {
return new URL(path, `${baseUrl}/`).toString();
}
function normalizePage(baseUrl: string, page: Record<string, unknown>, excerpt?: string) {
const links = (page._links ?? {}) as Record<string, unknown>;
return {
id: String(page.id ?? ""),
title: String(page.title ?? ""),
type: String(page.type ?? "page"),
...(page.status ? { status: String(page.status) } : {}),
...(page.spaceId ? { spaceId: String(page.spaceId) } : {}),
...(excerpt ? { excerpt } : {}),
...(links.webui ? { url: `${baseUrl}${String(links.webui)}` } : {}),
};
}
export function createConfluenceClient(options: ConfluenceClientOptions) {
const config = options.config;
async function getPageForUpdate(pageId: string) {
return (await sendJsonRequest({
config,
fetchImpl: options.fetchImpl,
url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${pageId}?body-format=storage`),
method: "GET",
errorPrefix: "Confluence request failed",
})) as Record<string, unknown>;
}
return {
async searchPages(input: SearchInput): Promise<CommandOutput<unknown>> {
const url = new URL("/wiki/rest/api/search", `${config.confluenceBaseUrl}/`);
url.searchParams.set("cql", input.query);
url.searchParams.set("limit", String(input.maxResults));
url.searchParams.set("start", String(input.startAt));
const raw = (await sendJsonRequest({
config,
fetchImpl: options.fetchImpl,
url: url.toString(),
method: "GET",
errorPrefix: "Confluence request failed",
})) as Record<string, unknown>;
const results = Array.isArray(raw.results) ? raw.results : [];
return {
ok: true,
data: {
pages: results.map((entry) => {
const result = entry as Record<string, unknown>;
return normalizePage(
config.baseUrl,
(result.content ?? {}) as Record<string, unknown>,
result.excerpt ? String(result.excerpt) : undefined,
);
}),
startAt: Number(raw.start ?? input.startAt),
maxResults: Number(raw.limit ?? input.maxResults),
total: Number(raw.totalSize ?? raw.size ?? results.length),
},
};
},
async getPage(pageId: string): Promise<CommandOutput<unknown>> {
const raw = (await sendJsonRequest({
config,
fetchImpl: options.fetchImpl,
url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${pageId}?body-format=storage`),
method: "GET",
errorPrefix: "Confluence request failed",
})) as Record<string, unknown>;
const body = ((raw.body ?? {}) as Record<string, unknown>).storage as Record<string, unknown> | undefined;
return {
ok: true,
data: {
page: {
...normalizePage(config.baseUrl, raw),
version: Number((((raw.version ?? {}) as Record<string, unknown>).number ?? 0)),
body: body?.value ? String(body.value) : "",
},
},
raw,
};
},
async listChildren(pageId: string, maxResults: number, startAt: number): Promise<CommandOutput<unknown>> {
const url = new URL(`/wiki/api/v2/pages/${pageId}/direct-children`, `${config.confluenceBaseUrl}/`);
url.searchParams.set("limit", String(maxResults));
url.searchParams.set("cursor", String(startAt));
const raw = (await sendJsonRequest({
config,
fetchImpl: options.fetchImpl,
url: url.toString(),
method: "GET",
errorPrefix: "Confluence request failed",
})) as Record<string, unknown>;
const results = Array.isArray(raw.results) ? raw.results : [];
const links = (raw._links ?? {}) as Record<string, unknown>;
return {
ok: true,
data: {
pages: results.map((page) => normalizePage(config.baseUrl, page as Record<string, unknown>)),
nextCursor: links.next ? String(links.next) : null,
},
};
},
async createPage(input: CreateInput): Promise<CommandOutput<unknown>> {
const spaceId = input.space || config.defaultSpace;
if (!spaceId) {
throw new Error("conf-create requires --space or ATLASSIAN_DEFAULT_SPACE");
}
const request = {
method: "POST" as const,
url: buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/pages"),
body: {
spaceId,
title: input.title,
status: "current",
body: {
representation: "storage",
value: input.body,
},
},
};
if (input.dryRun) return dryRunResponse(request);
const raw = await sendJsonRequest({
config,
fetchImpl: options.fetchImpl,
url: request.url,
method: request.method,
body: request.body,
errorPrefix: "Confluence request failed",
});
return {
ok: true,
data: raw,
};
},
async updatePage(input: UpdateInput): Promise<CommandOutput<unknown>> {
const currentPage = await getPageForUpdate(input.pageId);
const version = (((currentPage.version ?? {}) as Record<string, unknown>).number ?? 0) as number;
const spaceId = String(currentPage.spaceId ?? "");
const request = {
method: "PUT" as const,
url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${input.pageId}`),
body: {
id: input.pageId,
status: String(currentPage.status ?? "current"),
title: input.title,
spaceId,
version: {
number: Number(version) + 1,
},
body: {
representation: "storage",
value: input.body,
},
},
};
if (input.dryRun) return dryRunResponse(request);
const raw = await sendJsonRequest({
config,
fetchImpl: options.fetchImpl,
url: request.url,
method: request.method,
body: request.body,
errorPrefix: "Confluence request failed",
handleResponseError(response) {
if (response.status === 409) {
return new Error(`Confluence update conflict: page ${input.pageId} was updated by someone else`);
}
return undefined;
},
});
return {
ok: true,
data: raw,
};
},
async commentPage(input: CommentInput): Promise<CommandOutput<unknown>> {
const request = {
method: "POST" as const,
url: buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/footer-comments"),
body: {
pageId: input.pageId,
body: {
representation: "storage",
value: input.body,
},
},
};
if (input.dryRun) return dryRunResponse(request);
const raw = await sendJsonRequest({
config,
fetchImpl: options.fetchImpl,
url: request.url,
method: request.method,
body: request.body,
errorPrefix: "Confluence request failed",
});
return {
ok: true,
data: raw,
};
},
};
}
@@ -0,0 +1,14 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import { readFile } from "node:fs/promises";
import path from "node:path";
export async function readWorkspaceFile(filePath: string, cwd: string) {
const resolved = path.resolve(cwd, filePath);
const relative = path.relative(cwd, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`--body-file must stay within the active workspace: ${filePath}`);
}
return readFile(resolved, "utf8");
}
@@ -0,0 +1,70 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import { createJsonHeaders, createStatusError } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
type ProductHealth = {
ok: boolean;
status?: number;
message?: string;
};
function buildUrl(baseUrl: string, path: string) {
return new URL(path, `${baseUrl}/`).toString();
}
export async function runHealthCheck(
config: AtlassianConfig,
fetchImpl: FetchLike | undefined,
): Promise<CommandOutput<unknown>> {
const client = fetchImpl ?? globalThis.fetch;
if (!client) {
throw new Error("Fetch API is not available in this runtime");
}
async function probe(product: "Jira" | "Confluence", url: string): Promise<ProductHealth> {
try {
const response = await client(url, {
method: "GET",
headers: createJsonHeaders(config, false),
});
if (!response.ok) {
const error = createStatusError(`${product} health check failed`, response);
return {
ok: false,
status: response.status,
message: error.message,
};
}
return {
ok: true,
status: response.status,
};
} catch (error: unknown) {
return {
ok: false,
message: error instanceof Error ? error.message : String(error),
};
}
}
const jira = await probe("Jira", buildUrl(config.jiraBaseUrl, "/rest/api/3/myself"));
const confluence = await probe("Confluence", buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/spaces?limit=1"));
return {
ok: jira.ok && confluence.ok,
data: {
baseUrl: config.baseUrl,
jiraBaseUrl: config.jiraBaseUrl,
confluenceBaseUrl: config.confluenceBaseUrl,
defaultProject: config.defaultProject,
defaultSpace: config.defaultSpace,
products: {
jira,
confluence,
},
},
};
}
@@ -0,0 +1,87 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import { createBasicAuthHeader } from "./config.js";
import type { AtlassianConfig, FetchLike } from "./types.js";
export type HttpMethod = "GET" | "POST" | "PUT";
export function createJsonHeaders(config: AtlassianConfig, includeJsonBody: boolean) {
const headers: Array<[string, string]> = [
["Accept", "application/json"],
["Authorization", createBasicAuthHeader(config)],
];
if (includeJsonBody) {
headers.push(["Content-Type", "application/json"]);
}
return headers;
}
export async function parseResponse(response: Response) {
if (response.status === 204) {
return null;
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
try {
return await response.json();
} catch {
throw new Error("Malformed JSON response from Atlassian API");
}
}
return response.text();
}
export function createStatusError(errorPrefix: string, response: Response) {
const base = `${errorPrefix}: ${response.status} ${response.statusText}`;
switch (response.status) {
case 401:
return new Error(`${base} - check ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN`);
case 403:
return new Error(`${base} - verify product permissions for this account`);
case 404:
return new Error(`${base} - verify the resource identifier or API path`);
case 429:
return new Error(`${base} - retry later or reduce request rate`);
default:
return new Error(base);
}
}
export async function sendJsonRequest(options: {
config: AtlassianConfig;
fetchImpl?: FetchLike;
url: string;
method: HttpMethod;
body?: unknown;
errorPrefix: string;
handleResponseError?: (response: Response) => Error | undefined;
}) {
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
if (!fetchImpl) {
throw new Error("Fetch API is not available in this runtime");
}
const response = await fetchImpl(options.url, {
method: options.method,
headers: createJsonHeaders(options.config, options.body !== undefined),
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
});
if (!response.ok) {
const customError = options.handleResponseError?.(response);
if (customError) {
throw customError;
}
throw createStatusError(options.errorPrefix, response);
}
return parseResponse(response);
}
@@ -0,0 +1,242 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import { markdownToAdf } from "./adf.js";
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
const ISSUE_FIELDS = ["summary", "issuetype", "status", "assignee", "created", "updated"] as const;
type JiraClientOptions = {
config: AtlassianConfig;
fetchImpl?: FetchLike;
};
type SearchInput = {
jql: string;
maxResults: number;
startAt: number;
};
type CreateInput = {
project?: string;
type: string;
summary: string;
description?: string;
dryRun?: boolean;
};
type UpdateInput = {
issue: string;
summary?: string;
description?: string;
dryRun?: boolean;
};
type CommentInput = {
issue: string;
body: string;
dryRun?: boolean;
};
type TransitionInput = {
issue: string;
transition: string;
dryRun?: boolean;
};
function normalizeIssue(config: AtlassianConfig, issue: Record<string, unknown>): JiraIssueSummary {
const fields = (issue.fields ?? {}) as Record<string, unknown>;
const issueType = (fields.issuetype ?? {}) as Record<string, unknown>;
const status = (fields.status ?? {}) as Record<string, unknown>;
const assignee = (fields.assignee ?? {}) as Record<string, unknown>;
return {
key: String(issue.key ?? ""),
summary: String(fields.summary ?? ""),
issueType: String(issueType.name ?? ""),
status: String(status.name ?? ""),
assignee: assignee.displayName ? String(assignee.displayName) : undefined,
created: String(fields.created ?? ""),
updated: String(fields.updated ?? ""),
url: `${config.baseUrl}/browse/${issue.key ?? ""}`,
};
}
function createRequest(config: AtlassianConfig, method: "GET" | "POST" | "PUT", path: string, body?: unknown) {
const url = new URL(path, `${config.jiraBaseUrl}/`);
return {
method,
url: url.toString(),
...(body === undefined ? {} : { body }),
};
}
export function createJiraClient(options: JiraClientOptions) {
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
if (!fetchImpl) {
throw new Error("Fetch API is not available in this runtime");
}
async function send(method: "GET" | "POST" | "PUT", path: string, body?: unknown) {
const request = createRequest(options.config, method, path, body);
return sendJsonRequest({
config: options.config,
fetchImpl,
url: request.url,
method,
body,
errorPrefix: "Jira request failed",
});
}
return {
async searchIssues(input: SearchInput): Promise<CommandOutput<unknown>> {
const raw = (await send("POST", "/rest/api/3/search", {
jql: input.jql,
maxResults: input.maxResults,
startAt: input.startAt,
fields: [...ISSUE_FIELDS],
})) as Record<string, unknown>;
const issues = Array.isArray(raw.issues) ? raw.issues : [];
return {
ok: true,
data: {
issues: issues.map((issue) => normalizeIssue(options.config, issue as Record<string, unknown>)),
startAt: Number(raw.startAt ?? input.startAt),
maxResults: Number(raw.maxResults ?? input.maxResults),
total: Number(raw.total ?? issues.length),
},
};
},
async getIssue(issue: string): Promise<CommandOutput<unknown>> {
const url = new URL(`/rest/api/3/issue/${issue}`, `${options.config.jiraBaseUrl}/`);
url.searchParams.set("fields", ISSUE_FIELDS.join(","));
const raw = (await send("GET", `${url.pathname}${url.search}`)) as Record<string, unknown>;
return {
ok: true,
data: {
issue: normalizeIssue(options.config, raw),
},
raw,
};
},
async getTransitions(issue: string): Promise<CommandOutput<unknown>> {
const raw = (await send(
"GET",
`/rest/api/3/issue/${issue}/transitions`,
)) as { transitions?: Array<Record<string, unknown>> };
return {
ok: true,
data: {
transitions: (raw.transitions ?? []).map((transition) => ({
id: String(transition.id ?? ""),
name: String(transition.name ?? ""),
toStatus: String(((transition.to ?? {}) as Record<string, unknown>).name ?? ""),
hasScreen: Boolean(transition.hasScreen),
})),
},
};
},
async createIssue(input: CreateInput): Promise<CommandOutput<unknown>> {
const project = input.project || options.config.defaultProject;
if (!project) {
throw new Error("jira-create requires --project or ATLASSIAN_DEFAULT_PROJECT");
}
const request = createRequest(options.config, "POST", "/rest/api/3/issue", {
fields: {
project: { key: project },
issuetype: { name: input.type },
summary: input.summary,
...(input.description ? { description: markdownToAdf(input.description) } : {}),
},
});
if (input.dryRun) return dryRunResponse(request);
const raw = await send("POST", "/rest/api/3/issue", request.body);
return { ok: true, data: raw };
},
async updateIssue(input: UpdateInput): Promise<CommandOutput<unknown>> {
const fields: Record<string, unknown> = {};
if (input.summary) {
fields.summary = input.summary;
}
if (input.description) {
fields.description = markdownToAdf(input.description);
}
if (Object.keys(fields).length === 0) {
throw new Error("jira-update requires --summary and/or --description-file");
}
const request = createRequest(options.config, "PUT", `/rest/api/3/issue/${input.issue}`, {
fields,
});
if (input.dryRun) return dryRunResponse(request);
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
return {
ok: true,
data: {
issue: input.issue,
updated: true,
},
};
},
async commentIssue(input: CommentInput): Promise<CommandOutput<unknown>> {
const request = createRequest(options.config, "POST", `/rest/api/3/issue/${input.issue}/comment`, {
body: markdownToAdf(input.body),
});
if (input.dryRun) return dryRunResponse(request);
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
return {
ok: true,
data: raw,
};
},
async transitionIssue(input: TransitionInput): Promise<CommandOutput<unknown>> {
const request = createRequest(
options.config,
"POST",
`/rest/api/3/issue/${input.issue}/transitions`,
{
transition: {
id: input.transition,
},
},
);
if (input.dryRun) return dryRunResponse(request);
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
return {
ok: true,
data: {
issue: input.issue,
transitioned: true,
transition: input.transition,
},
};
},
};
}
@@ -0,0 +1,45 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import type { CommandOutput, OutputFormat, Writer } from "./types.js";
function renderText(payload: CommandOutput<unknown>) {
const data = payload.data as Record<string, unknown>;
if (Array.isArray(data?.issues)) {
return data.issues
.map((issue) => {
const item = issue as Record<string, string>;
return `${item.key} [${item.status}] ${item.issueType} - ${item.summary}`;
})
.join("\n");
}
if (data?.issue && typeof data.issue === "object") {
const issue = data.issue as Record<string, string>;
return [
issue.key,
`${issue.issueType} | ${issue.status}`,
issue.summary,
issue.url,
].join("\n");
}
if (Array.isArray(data?.transitions)) {
return data.transitions
.map((transition) => {
const item = transition as Record<string, string>;
return `${item.id} ${item.name} -> ${item.toStatus}`;
})
.join("\n");
}
return JSON.stringify(payload, null, 2);
}
export function writeOutput(
writer: Writer,
payload: CommandOutput<unknown>,
format: OutputFormat = "json",
) {
const body = format === "text" ? renderText(payload) : JSON.stringify(payload, null, 2);
writer.write(`${body}\n`);
}
@@ -0,0 +1,81 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
import { dryRunResponse } from "./command-helpers.js";
import { readWorkspaceFile } from "./files.js";
import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
const JIRA_ALLOWED_PREFIXES = ["/rest/api/3/"] as const;
const CONFLUENCE_ALLOWED_PREFIXES = ["/wiki/api/v2/", "/wiki/rest/api/"] as const;
type RawInput = {
product: "jira" | "confluence";
method: string;
path: string;
bodyFile?: string;
cwd: string;
dryRun?: boolean;
};
function getAllowedPrefixes(product: RawInput["product"]) {
return product === "jira" ? JIRA_ALLOWED_PREFIXES : CONFLUENCE_ALLOWED_PREFIXES;
}
function buildUrl(config: AtlassianConfig, product: RawInput["product"], path: string) {
const baseUrl = product === "jira" ? config.jiraBaseUrl : config.confluenceBaseUrl;
return new URL(path, `${baseUrl}/`).toString();
}
function validateMethod(method: string): asserts method is "GET" | "POST" | "PUT" {
if (!["GET", "POST", "PUT"].includes(method)) {
throw new Error("raw only allows GET, POST, and PUT");
}
}
function validatePath(product: RawInput["product"], path: string) {
const allowedPrefixes = getAllowedPrefixes(product);
if (!allowedPrefixes.some((prefix) => path.startsWith(prefix))) {
throw new Error(`raw path is not allowed for ${product}: ${path}`);
}
}
async function readRawBody(bodyFile: string | undefined, cwd: string) {
if (!bodyFile) {
return undefined;
}
const contents = await readWorkspaceFile(bodyFile, cwd);
return JSON.parse(contents) as unknown;
}
export async function runRawCommand(
config: AtlassianConfig,
fetchImpl: FetchLike | undefined,
input: RawInput,
): Promise<CommandOutput<unknown>> {
validateMethod(input.method);
validatePath(input.product, input.path);
const body = await readRawBody(input.bodyFile, input.cwd);
const request = {
method: input.method,
url: buildUrl(config, input.product, input.path),
...(body === undefined ? {} : { body }),
};
if (input.dryRun) return dryRunResponse(request);
const data = await sendJsonRequest({
config,
fetchImpl,
url: request.url,
method: input.method,
body,
errorPrefix: "Raw request failed",
});
return {
ok: true,
data,
};
}
@@ -0,0 +1,36 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
export type AtlassianConfig = {
baseUrl: string;
jiraBaseUrl: string;
confluenceBaseUrl: string;
email: string;
apiToken: string;
defaultProject?: string;
defaultSpace?: string;
};
export type CommandOutput<T> = {
ok: boolean;
data: T;
dryRun?: boolean;
raw?: unknown;
};
export type JiraIssueSummary = {
key: string;
summary: string;
issueType: string;
status: string;
assignee?: string;
created: string;
updated: string;
url: string;
};
export type Writer = {
write(chunk: string | Uint8Array): unknown;
};
export type FetchLike = typeof fetch;
export type OutputFormat = "json" | "text";
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"],
"outDir": "dist"
},
"include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"]
}
@@ -0,0 +1,31 @@
{
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
"generator": "scripts/generate-skills.mjs",
"generatedRoot": "pi-package/skills/create-plan",
"files": [
{
"path": "SKILL.md",
"kind": "file",
"mode": "644",
"sha256": "8767db25ce6f03e141ce4c48f37e9d7c4958c3cf3d70729f3bd7214b84f6d065"
},
{
"path": "templates/continuation-runbook.md",
"kind": "file",
"mode": "644",
"sha256": "1685cded3d4abaf03122a490175ff03b7da593ce60cbca97ae15fadcb706617f"
},
{
"path": "templates/milestone-plan.md",
"kind": "file",
"mode": "644",
"sha256": "364c08bf0a5ee3738195a0770db72c5a4c9ad7f7fb89eaa064eb8c67f47ad69a"
},
{
"path": "templates/story-tracker.md",
"kind": "file",
"mode": "644",
"sha256": "ecb550ea9dcd9dde6c813e90af9f538bf5a247fc249e8e323f2b7bf583e52196"
}
]
}
+236
View File
@@ -0,0 +1,236 @@
---
name: create-plan
description: Use when a user asks to create or maintain a structured implementation plan in pi, including milestones, bite-sized stories, and resumable local planning artifacts under ai_plan.
---
<!-- ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/create-plan/_source/pi/ and run `pnpm run sync:pi`. -->
# Create Plan (Pi)
Create and maintain a local plan workspace under `ai_plan/` at project root.
## Shared Setup
Before using this skill, read:
- [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md)
- [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md)
The workflow depends on:
- Obra Superpowers skills being visible to pi
- the pi reviewer-runtime helper being installed in a supported location
## Prerequisite Check (MANDATORY)
Required:
- `pi --version`
- Superpowers `brainstorming`
- Superpowers `writing-plans`
- pi reviewer runtime helper:
- `.pi/skills/reviewer-runtime/pi/run-review.sh`, or
- `~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh`
Quick checks for common installs:
```bash
pi --version
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md || test -f ~/.pi/agent/skills/superpowers/writing-plans/SKILL.md
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh
```
If you use a settings-defined skill path for Superpowers, verify it matches [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md) before continuing.
If you install the reviewer helper in a nonstandard location, verify it matches [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md) before continuing.
If any dependency is missing, stop and return:
`Missing dependency: pi planning requires Superpowers brainstorming/writing-plans skills plus the reviewer setup documented in docs/PI-SUPERPOWERS.md and docs/PI-COMMON-REVIEWER.md.`
## Required Workflow Rules
- Load the relevant workflow skill before entering its phase. If pi did not auto-load it, use `/skill:brainstorming` or `/skill:writing-plans`.
- Announce skill usage explicitly:
- `I've read the [Skill Name] skill and I'm using it to [purpose].`
- Track checklist-style progress inside the plan artifacts that this skill generates.
- Do not use deprecated wrapper CLIs.
## Process
### Phase 1: Analyze
- Explore the codebase and existing patterns.
- Review any current docs, scripts, or variant layouts that affect the plan.
### Phase 2: Gather Requirements
- Ask questions one at a time until the scope is clear.
- Confirm constraints, success criteria, dependencies, and what is out of scope.
### Phase 3: Configure Reviewer
If the user already specified a reviewer CLI and model, use those values. Otherwise ask:
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
1. Which CLI should review the plan?
- `codex`
- `claude`
- `cursor`
- `opencode`
- `pi`
- `skip`
2. Which model?
3. Max review rounds? Default: `10`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for the review loop.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
The pi reviewer command rendered into `/tmp/plan-review-${REVIEW_ID}.sh` must be isolated and read-only:
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files --model "$REVIEWER_MODEL" --tools read,grep,find,ls -p "Read the file /tmp/plan-${REVIEW_ID}.md and review."
```
The pi reviewer invocation must not load workflow skills and must not include `write`, `edit`, or `bash` tools.
### Phase 4: Design
- Load `brainstorming`.
- Present 2-3 approaches and recommend one.
- Resolve open design questions before the milestone breakdown.
### Phase 5: Plan
- Load `writing-plans`.
- Break the work into milestones and bite-sized stories.
- Story IDs should use the `S-101`, `S-102` style.
### Phase 6: Iterative Plan Review
Skip this phase if `REVIEWER_CLI=skip`.
#### Step 1: Generate Session ID
```bash
REVIEW_ID=$(uuidgen | tr '[:upper:]' '[:lower:]' | head -c 8)
```
Use these temp artifacts:
- `/tmp/plan-${REVIEW_ID}.md`
- `/tmp/plan-review-${REVIEW_ID}.md`
- `/tmp/plan-review-${REVIEW_ID}.json`
- `/tmp/plan-review-${REVIEW_ID}.stderr`
- `/tmp/plan-review-${REVIEW_ID}.status`
- `/tmp/plan-review-${REVIEW_ID}.runner.out`
- `/tmp/plan-review-${REVIEW_ID}.sh`
Resolve the pi reviewer runtime helper in this order:
```bash
REVIEWER_RUNTIME=""
for candidate in ".pi/skills/reviewer-runtime/pi/run-review.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/run-review.sh"; do
if [ -x "$candidate" ]; then
REVIEWER_RUNTIME="$candidate"
break
fi
done
```
#### Step 2: Write The Plan Payload
Write the full plan to `/tmp/plan-${REVIEW_ID}.md`.
Reviewer responses must use this structure:
```text
## Summary
...
## Findings
### P0
- ...
### P1
- ...
### P2
- ...
### P3
- ...
## Verdict
VERDICT: APPROVED
```
Rules:
- Order findings from `P0` to `P3`
- Use `- None.` when a severity has no findings
- `VERDICT: APPROVED` is valid only when no `P0`, `P1`, or `P2` findings remain
#### Step 3: Submit To Reviewer
Build a bash command script in `/tmp/plan-review-${REVIEW_ID}.sh` and execute it through the shared helper when present:
```bash
"$REVIEWER_RUNTIME" \
--command-file /tmp/plan-review-${REVIEW_ID}.sh \
--stdout-file /tmp/plan-review-${REVIEW_ID}.runner.out \
--stderr-file /tmp/plan-review-${REVIEW_ID}.stderr \
--status-file /tmp/plan-review-${REVIEW_ID}.status
```
Fallback to direct execution only if the helper is missing.
#### Step 4: Wait And Parse Verdict
- Keep waiting while fresh `state=in-progress note="In progress N"` heartbeats continue to appear.
- Treat `P0`, `P1`, or `P2` as must-fix findings.
- `P3` findings are non-blocking, but fix them when cheap and safe.
#### Step 5: Revise And Re-Submit
- Address findings in priority order.
- Rebuild the plan payload.
- Re-submit until approved or `MAX_ROUNDS` is reached.
### Phase 7: Generate Plan Files
Once the plan is approved:
1. Ensure `/ai_plan/` exists in `.gitignore`
2. Create `ai_plan/YYYY-MM-DD-<slug>/`
3. Write:
- `original-plan.md`
- `final-transcript.md`
- `milestone-plan.md`
- `story-tracker.md`
- `continuation-runbook.md`
4. Use the template files from this skill's `templates/` directory
### Phase 8: Telegram Completion Notification
Resolve the notification helper in this order:
```bash
TELEGRAM_NOTIFY_RUNTIME=""
for candidate in ".pi/skills/reviewer-runtime/pi/notify-telegram.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh"; do
if [ -x "$candidate" ]; then
TELEGRAM_NOTIFY_RUNTIME="$candidate"
break
fi
done
```
If the helper exists and both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are configured, send a short completion summary. If not, state that no Telegram completion notification was sent.
@@ -0,0 +1,146 @@
<!-- ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/create-plan/_source/pi/ and run `pnpm run sync:pi`. -->
# Continuation Runbook: [Plan Title]
## Reference Files (START HERE)
Upon resumption, these files in this folder are the ONLY source of truth:
| File | Purpose | When to Use |
|------|---------|-------------|
| `continuation-runbook.md` | Full context reproduction + execution workflow | Read FIRST |
| `story-tracker.md` | Current progress and status | Check/update BEFORE and AFTER every story |
| `milestone-plan.md` | Complete plan with specifications | Reference implementation details |
| `original-plan.md` | Original approved plan | Reference original intent |
| `final-transcript.md` | Final planning transcript | Reference reasoning/context |
Do NOT reference planner-private files during implementation.
## Skill Workflow Guardrails
- Load relevant skills before action. If pi did not auto-load them, use `/skill:<name>`.
- Announce which skill is being used and why.
- If a checklist-driven workflow applies, keep its state current in the plan artifacts.
- Do not use deprecated wrapper CLIs.
---
## Quick Resume Instructions
1. Read this runbook completely.
2. Check `story-tracker.md`.
3. Find next `pending` story and mark as `in-dev` before starting.
4. Implement the story.
5. Update tracker immediately after each change.
---
## Mandatory Execution Workflow
Work from this folder (`ai_plan/YYYY-MM-DD-<short-title>/`) and always follow this order:
1. Read `continuation-runbook.md` first.
2. Execute stories milestone by milestone.
3. After completing a milestone:
- Run lint/typecheck/tests, prioritizing changed files for speed.
- Commit locally (**DO NOT PUSH**).
- Stop and ask user for feedback.
4. If feedback is provided:
- Apply feedback changes.
- Re-run checks for changed files.
- Commit locally again.
- Ask for milestone approval.
5. Only move to next milestone after explicit approval.
6. After all milestones are completed and approved:
- Ask permission to push.
- If approved, push.
- Mark plan status as `completed`.
---
## Git Note
`ai_plan/` is intentionally local and must stay gitignored. Do not treat inability to commit plan-file updates inside `ai_plan/` as an error.
---
## Full Context Reproduction
### Project Overview
[What this project/feature is about]
### User Requirements
[All gathered requirements]
### Scope
[In scope / out of scope]
### Dependencies
[External dependencies, prerequisites, related systems]
---
## Key Specifications
### Type Definitions
```typescript
// Copy-paste ready type definitions
```
### Enums & Constants
```typescript
// All enums/constants needed
```
### API Endpoints
```typescript
// Request/response shapes
```
---
## Critical Design Decisions
| Decision | Chosen Approach | Alternatives Rejected | Rationale |
|----------|-----------------|----------------------|-----------|
| [Topic] | [What we chose] | [Other options] | [Why] |
---
## Verification Commands
### Lint (changed files first)
```bash
# example: pnpm eslint <changed-file-1> <changed-file-2>
```
### Typecheck
```bash
# example: pnpm tsc --noEmit
```
### Tests (target changed scope first)
```bash
# example: pnpm test -- <related spec/file>
```
---
## File Quick Reference
| File | Purpose |
|------|---------|
| `original-plan.md` | Original approved plan |
| `final-transcript.md` | Final planning transcript |
| `milestone-plan.md` | Full specification |
| `story-tracker.md` | Current progress tracker |
| `continuation-runbook.md` | This runbook |
@@ -0,0 +1,119 @@
<!-- ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/create-plan/_source/pi/ and run `pnpm run sync:pi`. -->
# [Plan Title]
## Overview
- **Goal:** [One sentence describing the end state]
- **Created:** YYYY-MM-DD
- **Status:** In Progress | Complete
## Context
### Requirements
[Gathered requirements from user questions]
### Constraints
[Technical, business, or timeline constraints]
### Success Criteria
[How we know this is complete]
## Architecture
### Design Decisions
[Key architectural choices and rationale]
### Component Relationships
[How pieces fit together]
### Data Flow
[How data moves through the system]
## Milestones
### M1: [Name]
**Description:** [What this milestone achieves]
**Acceptance Criteria:**
- [ ] [Criterion 1]
- [ ] [Criterion 2]
**Stories:** S-101, S-102, S-103...
**Milestone Completion Rule (MANDATORY):**
- Run lint/typecheck/tests for changed files.
- Commit locally (DO NOT push).
- Stop and ask user for feedback.
- Apply feedback, re-check changed files, commit again.
- Move to next milestone only after user approval.
---
### M2: [Name]
**Description:** [What this milestone achieves]
**Acceptance Criteria:**
- [ ] [Criterion 1]
- [ ] [Criterion 2]
**Stories:** S-201, S-202, S-203...
**Milestone Completion Rule (MANDATORY):**
- Run lint/typecheck/tests for changed files.
- Commit locally (DO NOT push).
- Stop and ask user for feedback.
- Apply feedback, re-check changed files, commit again.
- Move to next milestone only after user approval.
---
## Technical Specifications
### Types & Interfaces
```typescript
// Key type definitions
```
### API Contracts
```typescript
// Endpoint signatures, request/response shapes
```
### Constants & Enums
```typescript
// Shared constants
```
## Files Inventory
| File | Purpose | Milestone |
|------|---------|-----------|
| `path/to/file.ts` | [What it does] | M1 |
| `path/to/other.ts` | [What it does] | M2 |
---
## Related Plan Files
This file is part of the plan folder under `ai_plan/`:
- `original-plan.md` - Original approved plan (reference for original intent)
- `final-transcript.md` - Final planning transcript (reference for rationale/context)
- `milestone-plan.md` - This file (full specification)
- `story-tracker.md` - Status tracking (must be kept up to date)
- `continuation-runbook.md` - Resume/execution context (read first)
@@ -0,0 +1,72 @@
<!-- ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/create-plan/_source/pi/ and run `pnpm run sync:pi`. -->
# Story Tracker: [Plan Title]
## Progress Summary
- **Current Milestone:** M1
- **Stories Complete:** 0/N
- **Milestones Approved:** 0/M
- **Last Updated:** YYYY-MM-DD
---
## Milestones
### M1: [Name]
| Story | Description | Status | Notes |
|-------|-------------|--------|-------|
| S-101 | [Brief description] | pending | |
| S-102 | [Brief description] | pending | |
| S-103 | [Brief description] | pending | |
**Approval Status:** pending
---
### M2: [Name]
| Story | Description | Status | Notes |
|-------|-------------|--------|-------|
| S-201 | [Brief description] | pending | |
| S-202 | [Brief description] | pending | |
| S-203 | [Brief description] | pending | |
**Approval Status:** pending
---
## Status Legend
| Status | Meaning |
|--------|---------|
| `pending` | Not started |
| `in-dev` | Currently being worked on |
| `completed` | Done - include commit hash in Notes |
| `deferred` | Postponed - include reason in Notes |
## Update Instructions (MANDATORY)
Before starting any story:
1. Mark story as `in-dev`
2. Update "Last Updated"
After completing any story:
1. Mark story as `completed`
2. Add local commit hash to Notes
3. Update "Stories Complete" and "Last Updated"
At milestone boundary:
1. Run lint/typecheck/tests for changed files
2. Commit (no push)
3. Request feedback
4. Apply feedback, re-check changed files, commit again
5. Mark milestone **Approval Status: approved** only after user confirms
6. Continue only after approval
After all milestones approved:
- Ask permission to push and then mark plan completed.
@@ -0,0 +1,19 @@
{
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
"generator": "scripts/generate-skills.mjs",
"generatedRoot": "pi-package/skills/do-task",
"files": [
{
"path": "SKILL.md",
"kind": "file",
"mode": "644",
"sha256": "4920ad0cdeda546b37432c2268159724de54ddb01922308f2df88fcca4db8d31"
},
{
"path": "templates/task-plan.md",
"kind": "file",
"mode": "644",
"sha256": "fd38213fabf350e14b48c5209910d00c16ff74c455101618063835fa8c19e73e"
}
]
}
+212
View File
@@ -0,0 +1,212 @@
---
name: do-task
description: Execute a single user-supplied prompt end-to-end in pi with plan review, implementation review, verification, and one persistent task-plan artifact.
---
<!-- ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/do-task/_source/pi/ and run `pnpm run sync:pi`. -->
# Do Task (Pi)
Execute an ad-hoc user prompt end-to-end: parse, clarify, plan, implement, verify, review, commit, and optionally push.
This variant uses one persistent `task-plan.md` under `ai_plan/` and defaults to the current branch unless the prompt explicitly opts into a worktree workflow.
## Shared Setup
Before using this skill, read:
- [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md)
- [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md)
This workflow depends on:
- Superpowers skills being visible to pi
- the pi reviewer-runtime helper being installed in a supported location
## Prerequisite Check (MANDATORY)
Required:
- `pi --version`
- Superpowers `brainstorming`
- Superpowers `test-driven-development`
- Superpowers `verification-before-completion`
- Superpowers `finishing-a-development-branch`
- Superpowers `using-git-worktrees` when the prompt opts into a worktree
- pi reviewer runtime helper
- pi Telegram notifier helper
Quick checks for common installs:
```bash
pi --version
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.pi/agent/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh
```
If you use a settings-defined skill path for Superpowers, confirm it matches [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md) before continuing.
If you install the reviewer helper in a nonstandard location, confirm it matches [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md) before continuing.
If any required dependency is missing, stop immediately and return:
`Missing dependency: pi do-task requires the workflow skills and reviewer setup documented in docs/PI-SUPERPOWERS.md and docs/PI-COMMON-REVIEWER.md.`
## Required Workflow Rules
- Load the relevant workflow skill before entering its phase. If pi did not auto-load it, use `/skill:<name>`.
- Announce skill usage explicitly:
- `I've read the [Skill Name] skill and I'm using it to [purpose].`
- Keep the `task-plan.md` artifact current as work progresses.
- Do not use deprecated wrapper CLIs.
## Trigger Detection
Always use this skill for:
- `/do-task`
- `do this task`
- `do task ...`
- `execute this task`
- `make it so`
- `just do ...` when another skill is not a better fit
Use current-branch execution by default. Only switch to a worktree when the prompt explicitly asks for one.
## Process
### Phase 1: Preflight
1. Verify the repo: `git rev-parse --is-inside-work-tree`
2. Ensure `/ai_plan/` exists in `.gitignore`
3. Confirm the required workflow skills are available to pi
4. Announce each workflow skill before using it
### Phase 2: Parse Prompt And Clarify
1. Capture the user's prompt verbatim
2. Detect whether the prompt is concrete enough to proceed without questions
3. If needed, ask 1-3 short questions one at a time
4. Load `brainstorming` for behavior-changing work unless the task is pure documentation or pure comment/whitespace/rename work
### Phase 3: Configure Reviewer
If the user already specified reviewer settings, use them. Otherwise ask:
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
1. Which CLI should review the plan and implementation?
2. Reviewer model
3. Max rounds, default `10`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
The pi reviewer command rendered into `/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.sh` must be isolated and read-only:
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files --model "$REVIEWER_MODEL" --tools read,grep,find,ls -p "Read the file /tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md and review."
```
The pi reviewer invocation must not load workflow skills and must not include `write`, `edit`, or `bash` tools.
### Phase 4: Initialize `task-plan.md`
1. Compute `ai_plan/YYYY-MM-DD-<slug>/`
2. Resume if an existing plan folder is active, otherwise create a new one
3. Write `task-plan.md` from this skill's `templates/task-plan.md`
4. Fill `Metadata`, `Prompt`, `Interpretation`, `Assumptions`, `Files`, `Approach`, `TDD Approach`, `Acceptance Criteria`, `Verification`, and `Rollback`
5. Set `Status: draft`
If the prompt explicitly opts into a worktree, load `using-git-worktrees` before implementation. Otherwise remain on the current branch.
### Phase 5: Plan Review Loop
Skip this phase if `REVIEWER_CLI=skip`.
1. Write a reviewer payload from `task-plan.md`
2. Strip the runtime-only sections before sending it out
3. Run the reviewer through the pi reviewer-runtime helper when available
4. Fix `P0`, `P1`, and `P2` findings before proceeding
5. Keep `P3` findings for optional cleanup
6. Set `Status: plan-approved` when the reviewer approves
The reviewer response format must be:
```text
## Summary
...
## Findings
### P0
- ...
### P1
- ...
### P2
- ...
### P3
- ...
## Verdict
VERDICT: APPROVED
```
### Phase 6: Execute
1. Set `Status: implementation-in-progress`
2. Load `test-driven-development` for every behavior-changing edit unless `task-plan.md` explicitly records an allowed skip
3. Update `task-plan.md` as acceptance criteria are completed
4. Do not commit yet
### Phase 7: Verification Gate
1. Load `verification-before-completion`
2. Run the commands listed in `task-plan.md`
3. Fix failures and re-run verification until green
4. If verification stalls repeatedly, stop and surface the blocker
### Phase 8: Implementation Review Loop
Skip this phase if `REVIEWER_CLI=skip`.
1. Build a review payload from the approved plan, current diff, and verification output
2. Run the reviewer through the pi reviewer-runtime helper
3. Address `P0`, `P1`, and `P2` findings before approval
4. Fix cheap `P3` findings when safe
5. Set `Status: implementation-approved` when approved
### Phase 9: Commit And Push Decision
1. Load `finishing-a-development-branch`
2. Stage only the intended files
3. Create one commit for the task
4. Ask whether to push or keep the work local
### Phase 10: Telegram Completion Notification
Resolve the helper in this order:
```bash
TELEGRAM_NOTIFY_RUNTIME=""
for candidate in ".pi/skills/reviewer-runtime/pi/notify-telegram.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh"; do
if [ -x "$candidate" ]; then
TELEGRAM_NOTIFY_RUNTIME="$candidate"
break
fi
done
```
If the helper exists and both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are configured, send a short completion summary. Otherwise state that no Telegram completion notification was sent.
@@ -0,0 +1,129 @@
<!-- ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/do-task/_source/pi/ and run `pnpm run sync:pi`. -->
# Task Plan: [Short Title]
> **Variant guardrail (pi):** Required workflow skills (`brainstorming`, `test-driven-development`, `verification-before-completion`, `finishing-a-development-branch`, `using-git-worktrees`) must be available to pi as documented in `docs/PI-SUPERPOWERS.md`. Load the relevant workflow skill before entering its matching phase.
## Metadata
| Field | Value |
|-------|-------|
| Created | YYYY-MM-DD |
| Slug | YYYY-MM-DD-<slug> |
| Runtime | pi |
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
| Reviewer Model | <model> |
| MAX_ROUNDS | 10 |
| Branch Strategy | current-branch \| worktree |
| Branch Name | <current branch name, or new branch name when worktree is used> |
| Worktree Path | <absolute path to worktree dir; blank when Branch Strategy = current-branch> |
| Status | draft |
### Status Enum (authoritative)
| Value | Meaning |
|-------|---------|
| `draft` | Newly created; plan review not yet started |
| `plan-approved` | Plan review loop returned APPROVED |
| `implementation-in-progress` | Phase 6 executing |
| `implementation-approved` | Phase 8 review loop returned APPROVED; awaiting commit |
| `pushed` | Committed + pushed to remote |
| `local-only` | Committed locally; user declined push |
| `aborted-plan-review` | MAX_ROUNDS reached in Phase 5; user aborted |
| `aborted-impl-review` | MAX_ROUNDS reached in Phase 8; user aborted |
| `aborted-verification` | Phase 7 retries exhausted; user aborted |
| `failed` | Hard tooling failure |
---
## Prompt
<!-- Exact user prompt, verbatim. -->
## Interpretation
<!-- Short restatement of goal + out-of-scope items. -->
## Assumptions
<!-- Anything we're assuming and needs confirmation. Empty list OK after clarifying questions. -->
## Files
<!-- Files expected to be created / modified / deleted. Paths are absolute or repo-relative. -->
| Action | Path | Why |
|--------|------|-----|
| | | |
## Approach
<!-- 3-10 bullets describing implementation order. -->
## TDD Approach
<!-- One of:
(a) **TDD applies** — list the failing test(s) to write first, then implementation, then confirm green.
(b) **TDD auto-skipped** — reason must be exactly one of:
- `pure-documentation`
- `pure-comment-whitespace-rename`
(c) **TDD user-approved skip** — user explicitly approved skipping TDD for this task.
Record the approval timestamp (ISO-8601) and the specific reason.
-->
## Acceptance Criteria
- [ ] <criterion 1>
- [ ] <criterion 2>
## Verification
<!-- Commands to run:
lint: <cmd>
typecheck: <cmd>
tests: <cmd>
-->
## Rollback
<!-- How to undo: `git revert <hash>`, or manual steps if the change is not easily reversible. -->
---
## Runtime State
```yaml
plan_review_round: 0
implementation_review_round: 0
CODEX_PLAN_SESSION_ID:
CODEX_IMPL_SESSION_ID:
CURSOR_PLAN_SESSION_ID:
CURSOR_IMPL_SESSION_ID:
OPENCODE_PLAN_SESSION_ID:
OPENCODE_IMPL_SESSION_ID:
last_phase_entered:
last_round_ts:
last_scan_outcome_plan:
last_scan_outcome_impl:
verification_attempts: 0
tests_added_count: 0
tdd_used: false
```
## Review History
| Timestamp (ISO-8601) | Loop | Round | Verdict | Summary |
|----------------------|------|-------|---------|---------|
| | | | | |
## Final Status
<!-- Populate the terminal status, commit hash if any, rounds used, TDD usage, tests added, verification attempts, and any revisit notes. -->
---
## Guardrails (do NOT remove)
- This file is the single persistent artifact for `do-task`. Do not split it or delete it on success.
- `Status` must always match one of the enum values.
- `Runtime State` is updated by the skill, not by the user.
- Review History is append-only.
@@ -0,0 +1,13 @@
{
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
"generator": "scripts/generate-skills.mjs",
"generatedRoot": "pi-package/skills/implement-plan",
"files": [
{
"path": "SKILL.md",
"kind": "file",
"mode": "644",
"sha256": "578e5b4fbc443ade486e4aa034b0875ebfa7eefd3a1268852ac1c5cbf71f28bf"
}
]
}
+243
View File
@@ -0,0 +1,243 @@
---
name: implement-plan
description: Use when a plan folder created by create-plan must be executed in pi with milestone verification, reviewer gates, local commits, and resumable tracker updates.
---
<!-- ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/implement-plan/_source/pi/ and run `pnpm run sync:pi`. -->
# Implement Plan (Pi)
Execute an existing plan under `ai_plan/` milestone by milestone, using verification gates, reviewer approval, and local commits after each approved milestone.
## Shared Setup
Before using this skill, read:
- [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md)
- [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md)
This workflow depends on:
- Superpowers execution skills being visible to pi
- the pi reviewer-runtime helper being installed in a supported location
## Prerequisite Check (MANDATORY)
Required:
- `pi --version`
- a plan folder under `ai_plan/`
- `continuation-runbook.md`
- `milestone-plan.md`
- `story-tracker.md`
- git worktree support
- Superpowers `executing-plans`
- Superpowers `using-git-worktrees`
- Superpowers `verification-before-completion`
- Superpowers `finishing-a-development-branch`
- pi reviewer runtime helper
- pi Telegram notifier helper
Quick checks for common installs:
```bash
pi --version
git worktree list
test -f ~/.agents/skills/superpowers/executing-plans/SKILL.md || test -f ~/.pi/agent/skills/superpowers/executing-plans/SKILL.md
test -f ~/.agents/skills/superpowers/using-git-worktrees/SKILL.md || test -f ~/.pi/agent/skills/superpowers/using-git-worktrees/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh
```
If you use a settings-defined skill path for Superpowers, confirm it matches [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md) before continuing.
If you install the reviewer helper in a nonstandard location, confirm it matches [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md) before continuing.
If any dependency is missing, stop and return:
`Missing dependency: pi implement-plan requires the execution skills and reviewer setup documented in docs/PI-SUPERPOWERS.md and docs/PI-COMMON-REVIEWER.md.`
## Required Workflow Rules
- Load the relevant workflow skill before entering its phase. If pi did not auto-load it, use `/skill:<name>`.
- Announce skill usage explicitly:
- `I've read the [Skill Name] skill and I'm using it to [purpose].`
- Update `story-tracker.md` before starting and after completing every story.
- Do not use deprecated wrapper CLIs.
## Process
### Phase 1: Locate Plan
1. Scan `ai_plan/` and identify the target plan folder
2. Read `continuation-runbook.md` first
3. Read `story-tracker.md` to identify resume state
4. Read `milestone-plan.md` for the implementation spec
### Phase 2: Configure Reviewer
If the user already provided reviewer settings, use them. Otherwise ask:
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
1. Which CLI should review milestone implementations?
2. Reviewer model
3. Max rounds, default `10`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
The pi reviewer command rendered into `/tmp/milestone-review-${REVIEW_ID}.sh` must be isolated and read-only:
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files --model "$REVIEWER_MODEL" --tools read,grep,find,ls -p "Read the file /tmp/milestone-${REVIEW_ID}.md and review."
```
The pi reviewer invocation must not load workflow skills and must not include `write`, `edit`, or `bash` tools.
### Phase 3: Set Up Workspace
1. Load `using-git-worktrees`
2. Create or resume the implementation branch/worktree described by the plan
3. Verify baseline setup and tests before changing code
### Phase 4: Execute Milestones
For each milestone:
1. Mark the next story `in-dev` in `story-tracker.md`
2. Implement the story
3. Mark the story `completed`
4. Continue until the milestone stories are done
5. Load `verification-before-completion`
6. Run lint, typecheck, and tests for the changed scope
7. Send the milestone diff and verification output to the reviewer before committing
8. Commit only after approval
### Phase 5: Milestone Review Loop
Skip this phase if `REVIEWER_CLI=skip`.
#### Step 1: Generate Session ID
```bash
REVIEW_ID=$(uuidgen | tr '[:upper:]' '[:lower:]' | head -c 8)
```
Use these temp artifacts:
- `/tmp/milestone-${REVIEW_ID}.md`
- `/tmp/milestone-review-${REVIEW_ID}.md`
- `/tmp/milestone-review-${REVIEW_ID}.json`
- `/tmp/milestone-review-${REVIEW_ID}.stderr`
- `/tmp/milestone-review-${REVIEW_ID}.status`
- `/tmp/milestone-review-${REVIEW_ID}.runner.out`
- `/tmp/milestone-review-${REVIEW_ID}.sh`
Resolve the pi reviewer runtime helper in this order:
```bash
REVIEWER_RUNTIME=""
for candidate in ".pi/skills/reviewer-runtime/pi/run-review.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/run-review.sh"; do
if [ -x "$candidate" ]; then
REVIEWER_RUNTIME="$candidate"
break
fi
done
```
#### Step 2: Build Review Payload
Write the milestone spec, acceptance criteria, diff, and verification output to `/tmp/milestone-${REVIEW_ID}.md`.
Reviewer responses must use this structure:
```text
## Summary
...
## Findings
### P0
- ...
### P1
- ...
### P2
- ...
### P3
- ...
## Verdict
VERDICT: APPROVED
```
Rules:
- Order findings from `P0` to `P3`
- Use `- None.` when a severity has no findings
- `VERDICT: APPROVED` is valid only when no `P0`, `P1`, or `P2` findings remain
#### Step 3: Run Review
Execute the reviewer command script through the helper when available:
```bash
"$REVIEWER_RUNTIME" \
--command-file /tmp/milestone-review-${REVIEW_ID}.sh \
--stdout-file /tmp/milestone-review-${REVIEW_ID}.runner.out \
--stderr-file /tmp/milestone-review-${REVIEW_ID}.stderr \
--status-file /tmp/milestone-review-${REVIEW_ID}.status
```
Fallback to direct execution only if the helper is missing.
#### Step 4: Handle Findings
- Keep waiting while fresh `state=in-progress note="In progress N"` heartbeats continue
- Fix `P0`, `P1`, and `P2` findings before approval
- Fix cheap `P3` findings when safe
- Re-run verification after each revision
### Phase 6: Commit And Track Approval
After milestone approval:
1. Commit the milestone locally
2. Backfill the commit hash into that milestone's story notes
3. Mark the milestone `approved` in `story-tracker.md`
4. Move to the next milestone
### Phase 7: Finalization
After all milestones are approved:
1. Load `finishing-a-development-branch`
2. Run the full verification suite
3. Ask whether to push or keep the work local
4. Mark the plan completed in `story-tracker.md`
### Phase 8: Telegram Completion Notification
Resolve the helper in this order:
```bash
TELEGRAM_NOTIFY_RUNTIME=""
for candidate in ".pi/skills/reviewer-runtime/pi/notify-telegram.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh"; do
if [ -x "$candidate" ]; then
TELEGRAM_NOTIFY_RUNTIME="$candidate"
break
fi
done
```
If the helper exists and both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are configured, send a short completion summary. Otherwise state that no Telegram completion notification was sent.
@@ -0,0 +1,103 @@
{
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
"generator": "scripts/generate-skills.mjs",
"generatedRoot": "pi-package/skills/web-automation",
"files": [
{
"path": "scripts/auth.ts",
"kind": "file",
"mode": "644",
"sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505"
},
{
"path": "scripts/browse.ts",
"kind": "file",
"mode": "644",
"sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8"
},
{
"path": "scripts/check-install.js",
"kind": "file",
"mode": "644",
"sha256": "e46ee8cbe103794bf1e9c3466bb0fbd21079ceddc60ad9521299e8bc0150e48f"
},
{
"path": "scripts/extract.js",
"kind": "file",
"mode": "644",
"sha256": "6fa2a0589de8afd6501e332e5fa263e1344187ea43a33590b431cdee59d04217"
},
{
"path": "scripts/flow.ts",
"kind": "file",
"mode": "644",
"sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366"
},
{
"path": "scripts/lib/browser.ts",
"kind": "file",
"mode": "644",
"sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934"
},
{
"path": "scripts/package.json",
"kind": "file",
"mode": "644",
"sha256": "1d9226da585c65106dacd874e5e6c7951f5a5b2b0f0cf5902f305a951ca4b44d"
},
{
"path": "scripts/pnpm-lock.yaml",
"kind": "file",
"mode": "644",
"sha256": "17017e15e8b04311f5d53bdd37065b2f5a514a3119f40a0403148440ed181437"
},
{
"path": "scripts/scan-local-app.ts",
"kind": "file",
"mode": "644",
"sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7"
},
{
"path": "scripts/scrape.ts",
"kind": "file",
"mode": "644",
"sha256": "a1a3d81d57d9e8ab1854ce3cb230bdd39ae1087ec50c9fe82cc58f5f2663ebeb"
},
{
"path": "scripts/test-full.ts",
"kind": "file",
"mode": "644",
"sha256": "76a647e840753621445c36894bff62e163f6a2e4d0860fa8e64d8df45fe21e08"
},
{
"path": "scripts/test-minimal.ts",
"kind": "file",
"mode": "644",
"sha256": "59e0b2319d3f7521b2a8a4fca2d779afaa157bf2d160160fdec8cb56bea30b4f"
},
{
"path": "scripts/test-profile.ts",
"kind": "file",
"mode": "644",
"sha256": "6cf0141581a9275bfa8a070a36212cef5f6417d64df3df3e614ec682008376b9"
},
{
"path": "scripts/tsconfig.json",
"kind": "file",
"mode": "644",
"sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d"
},
{
"path": "scripts/turndown-plugin-gfm.d.ts",
"kind": "file",
"mode": "644",
"sha256": "c5001c059b160eff18a4097a8a0a7b96689b4ebc374543c7d5bf6e40b0d8a5ac"
},
{
"path": "SKILL.md",
"kind": "file",
"mode": "644",
"sha256": "7ff56c1c50697439875f4dd0a7f7697962c8ba2105a4f66ab7b170f5dcc762bd"
}
]
}
+124
View File
@@ -0,0 +1,124 @@
---
name: web-automation
description: Browse and scrape web pages using Playwright-compatible CloakBrowser. Use when automating web workflows, extracting rendered page content, handling authenticated sessions, or running multi-step browser flows.
---
<!-- ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/_source/pi/SKILL.md and run `pnpm run sync:pi`. -->
# Web Automation with CloakBrowser (Pi)
Automated web browsing and scraping for pi using the shared runtime bundle in `scripts/`.
## Requirements
- Node.js 20+
- `pnpm`
- Network access to download the CloakBrowser binary on first use
## First-Time Setup
Global install:
```bash
mkdir -p ~/.pi/agent/skills/web-automation
cp -R skills/web-automation/pi/* ~/.pi/agent/skills/web-automation/
cd ~/.pi/agent/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Project-local install:
```bash
mkdir -p .pi/skills/web-automation
cp -R skills/web-automation/pi/* .pi/skills/web-automation/
cd .pi/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Pi can also load this repo through settings or package installs as documented in [docs/PI.md](../../../docs/PI.md).
If you installed this repo from a local checkout with `./scripts/install-pi-package.sh`, the runtime stays in the checkout mirror at `pi-package/skills/web-automation/scripts`.
## Updating CloakBrowser
Run inside the installed `scripts/` directory for the pi skill. The commands below work for both global and project-local installs as long as you run them from the installed `scripts/` directory.
```bash
pnpm up cloakbrowser playwright-core
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
## Prerequisite Check (MANDATORY)
Before running automation, verify the runtime from the location that matches your install style:
- local checkout package install: `pi-package/skills/web-automation/scripts`
- project-local copied install: `.pi/skills/web-automation/scripts`
- global copied install: `~/.pi/agent/skills/web-automation/scripts`
```bash
cd pi-package/skills/web-automation/scripts
node check-install.js
```
If the check fails, stop and return:
`Missing dependency/config: web-automation requires cloakbrowser and playwright-core with CloakBrowser-based scripts. Run setup in this skill, then retry.`
If runtime fails with missing native bindings for `better-sqlite3` or `esbuild`, run the same commands from your installed `scripts/` directory:
```bash
cd pi-package/skills/web-automation/scripts
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
## When To Use Which Command
- Use `node extract.js "<URL>"` for a one-shot rendered fetch with JSON output.
- Use `npx tsx scrape.ts ...` when you need markdown extraction, Readability cleanup, or selector-based scraping.
- Use `npx tsx browse.ts ...`, `auth.ts`, or `flow.ts` when the task needs login handling, persistent sessions, clicks, typing, screenshots, or multi-step navigation.
- Use `npx tsx scan-local-app.ts` when you need a configurable local-app smoke pass driven by `SCAN_*` and `CLOAKBROWSER_*` environment variables.
## Quick Reference
- Install check: `node check-install.js`
- One-shot JSON extract: `node extract.js "https://example.com"`
- Browse page: `npx tsx browse.ts --url "https://example.com"`
- Scrape markdown: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md`
- Authenticate: `npx tsx auth.ts --url "https://example.com/login"`
- Natural-language flow: `npx tsx flow.ts --instruction 'go to https://example.com then click on "Login" then type "user@example.com" in #email then press enter'`
- Local app smoke scan: `SCAN_BASE_URL=http://localhost:3000 SCAN_ROUTES=/,/dashboard npx tsx scan-local-app.ts`
## Local App Smoke Scan
`scan-local-app.ts` is intentionally generic. Configure it with environment variables instead of editing the file:
- `SCAN_BASE_URL`
- `SCAN_LOGIN_PATH`
- `SCAN_USERNAME`
- `SCAN_PASSWORD`
- `SCAN_USERNAME_SELECTOR`
- `SCAN_PASSWORD_SELECTOR`
- `SCAN_SUBMIT_SELECTOR`
- `SCAN_ROUTES`
- `SCAN_REPORT_PATH`
- `SCAN_HEADLESS`
If `SCAN_USERNAME` or `SCAN_PASSWORD` are omitted, the script falls back to `CLOAKBROWSER_USERNAME` and `CLOAKBROWSER_PASSWORD`.
## Notes
- Sessions persist in CloakBrowser profile storage.
- Use `--wait` for dynamic pages.
- Use `--mode selector --selector "..."` for targeted extraction.
- `extract.js` keeps a bounded stealth/rendered fetch path without needing a long-lived automation session.
- Package installs use the repo's `pi-package/skills/web-automation/` mirror so the installed skill directory name matches `web-automation`.
@@ -0,0 +1,576 @@
#!/usr/bin/env npx tsx
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
/**
* Authentication handler for web automation
* Supports generic form login and Microsoft SSO (MSAL)
*
* Usage:
* npx tsx auth.ts --url "https://example.com/login" --type form
* npx tsx auth.ts --url "https://example.com" --type msal
* npx tsx auth.ts --url "https://example.com" --type auto
*/
import { getPage, launchBrowser } from './lib/browser.js';
import parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline';
// Types
type AuthType = 'auto' | 'form' | 'msal';
interface AuthOptions {
url: string;
authType: AuthType;
credentials?: {
username: string;
password: string;
};
headless?: boolean;
timeout?: number;
}
interface AuthResult {
success: boolean;
finalUrl: string;
authType: AuthType;
message: string;
}
// Get credentials from environment or options
function getCredentials(options?: {
username?: string;
password?: string;
}): { username: string; password: string } | null {
const username = options?.username || process.env.CLOAKBROWSER_USERNAME;
const password = options?.password || process.env.CLOAKBROWSER_PASSWORD;
if (!username || !password) {
return null;
}
return { username, password };
}
// Prompt user for input (for MFA or credentials)
async function promptUser(question: string, hidden = false): Promise<string> {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
if (hidden) {
process.stdout.write(question);
// Note: This is a simple implementation. For production, use a proper hidden input library
}
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
// Detect authentication type from page
async function detectAuthType(page: Page): Promise<AuthType> {
const url = page.url();
// Check for Microsoft login
if (
url.includes('login.microsoftonline.com') ||
url.includes('login.live.com') ||
url.includes('login.windows.net')
) {
return 'msal';
}
// Check for common form login patterns
const hasLoginForm = await page.evaluate(() => {
const passwordField = document.querySelector(
'input[type="password"], input[name*="password"], input[id*="password"]'
);
const usernameField = document.querySelector(
'input[type="email"], input[type="text"][name*="user"], input[type="text"][name*="email"], input[id*="user"], input[id*="email"]'
);
return !!(passwordField && usernameField);
});
if (hasLoginForm) {
return 'form';
}
return 'auto';
}
// Handle generic form login
async function handleFormLogin(
page: Page,
credentials: { username: string; password: string },
timeout: number
): Promise<boolean> {
console.log('Attempting form login...');
// Find and fill username/email field
const usernameSelectors = [
'input[type="email"]',
'input[name*="user" i]',
'input[name*="email" i]',
'input[id*="user" i]',
'input[id*="email" i]',
'input[autocomplete="username"]',
'input[type="text"]:first-of-type',
];
let usernameField = null;
for (const selector of usernameSelectors) {
usernameField = await page.$(selector);
if (usernameField) break;
}
if (!usernameField) {
console.error('Could not find username/email field');
return false;
}
await usernameField.fill(credentials.username);
console.log('Filled username field');
// Find and fill password field
const passwordSelectors = [
'input[type="password"]',
'input[name*="password" i]',
'input[id*="password" i]',
'input[autocomplete="current-password"]',
];
let passwordField = null;
for (const selector of passwordSelectors) {
passwordField = await page.$(selector);
if (passwordField) break;
}
if (!passwordField) {
console.error('Could not find password field');
return false;
}
await passwordField.fill(credentials.password);
console.log('Filled password field');
// Check for "Remember me" checkbox and check it
const rememberCheckbox = await page.$(
'input[type="checkbox"][name*="remember" i], input[type="checkbox"][id*="remember" i]'
);
if (rememberCheckbox) {
await rememberCheckbox.check();
console.log('Checked "Remember me" checkbox');
}
// Find and click submit button
const submitSelectors = [
'button[type="submit"]',
'input[type="submit"]',
'button:has-text("Sign in")',
'button:has-text("Log in")',
'button:has-text("Login")',
'button:has-text("Submit")',
'[role="button"]:has-text("Sign in")',
];
let submitButton = null;
for (const selector of submitSelectors) {
submitButton = await page.$(selector);
if (submitButton) break;
}
if (!submitButton) {
// Try pressing Enter as fallback
await passwordField.press('Enter');
} else {
await submitButton.click();
}
console.log('Submitted login form');
// Wait for navigation or error
try {
await page.waitForNavigation({ timeout, waitUntil: 'domcontentloaded' });
return true;
} catch {
// Check if we're still on login page with error
const errorMessages = await page.$$eval(
'.error, .alert-danger, [role="alert"], .login-error',
(els) => els.map((el) => el.textContent?.trim()).filter(Boolean)
);
if (errorMessages.length > 0) {
console.error('Login error:', errorMessages.join(', '));
return false;
}
return true; // Might have succeeded without navigation
}
}
// Handle Microsoft SSO login
async function handleMsalLogin(
page: Page,
credentials: { username: string; password: string },
timeout: number
): Promise<boolean> {
console.log('Attempting Microsoft SSO login...');
const currentUrl = page.url();
// If not already on Microsoft login, wait for redirect
if (!currentUrl.includes('login.microsoftonline.com')) {
try {
await page.waitForURL('**/login.microsoftonline.com/**', { timeout: 10000 });
} catch {
console.log('Not redirected to Microsoft login');
return false;
}
}
// Wait for email input
const emailInput = await page.waitForSelector(
'input[type="email"], input[name="loginfmt"]',
{ timeout }
);
if (!emailInput) {
console.error('Could not find email input on Microsoft login');
return false;
}
// Fill email and submit
await emailInput.fill(credentials.username);
console.log('Filled email field');
const nextButton = await page.$('input[type="submit"], button[type="submit"]');
if (nextButton) {
await nextButton.click();
} else {
await emailInput.press('Enter');
}
// Wait for password page
try {
await page.waitForSelector(
'input[type="password"], input[name="passwd"]',
{ timeout }
);
} catch {
// Might be using passwordless auth or different flow
console.log('Password field not found - might be using different auth flow');
return false;
}
// Fill password
const passwordInput = await page.$('input[type="password"], input[name="passwd"]');
if (!passwordInput) {
console.error('Could not find password input');
return false;
}
await passwordInput.fill(credentials.password);
console.log('Filled password field');
// Submit
const signInButton = await page.$('input[type="submit"], button[type="submit"]');
if (signInButton) {
await signInButton.click();
} else {
await passwordInput.press('Enter');
}
// Handle "Stay signed in?" prompt
try {
const staySignedInButton = await page.waitForSelector(
'input[value="Yes"], button:has-text("Yes")',
{ timeout: 5000 }
);
if (staySignedInButton) {
await staySignedInButton.click();
console.log('Clicked "Stay signed in" button');
}
} catch {
// Prompt might not appear
}
// Check for Conditional Access Policy error
const caError = await page.$('text=Conditional Access policy');
if (caError) {
console.error('Blocked by Conditional Access Policy');
// Take screenshot for debugging
await page.screenshot({ path: 'ca-policy-error.png' });
console.log('Screenshot saved: ca-policy-error.png');
return false;
}
// Wait for redirect away from Microsoft login
try {
await page.waitForURL(
(url) => !url.href.includes('login.microsoftonline.com'),
{ timeout }
);
return true;
} catch {
return false;
}
}
// Check if user is already authenticated
async function isAuthenticated(page: Page, targetUrl: string): Promise<boolean> {
const currentUrl = page.url();
// If we're on the target URL (not a login page), we're likely authenticated
if (currentUrl.startsWith(targetUrl)) {
// Check for common login page indicators
const isLoginPage = await page.evaluate(() => {
const loginIndicators = [
'input[type="password"]',
'form[action*="login"]',
'form[action*="signin"]',
'.login-form',
'#login',
];
return loginIndicators.some((sel) => document.querySelector(sel) !== null);
});
return !isLoginPage;
}
return false;
}
// Main authentication function
export async function authenticate(options: AuthOptions): Promise<AuthResult> {
const browser = await launchBrowser({ headless: options.headless ?? true });
const page = await browser.newPage();
const timeout = options.timeout ?? 30000;
try {
// Navigate to URL
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, { timeout: 60000, waitUntil: 'domcontentloaded' });
// Check if already authenticated
if (await isAuthenticated(page, options.url)) {
return {
success: true,
finalUrl: page.url(),
authType: 'auto',
message: 'Already authenticated (session persisted from profile)',
};
}
// Get credentials
const credentials = options.credentials
? options.credentials
: getCredentials();
if (!credentials) {
// No credentials - open interactive browser
console.log('\nNo credentials provided. Opening browser for manual login...');
console.log('Please complete the login process manually.');
console.log('The session will be saved to your profile.');
// Switch to headed mode for manual login
await browser.close();
const interactiveBrowser = await launchBrowser({ headless: false });
const interactivePage = await interactiveBrowser.newPage();
await interactivePage.goto(options.url);
await promptUser('\nPress Enter when you have completed login...');
const finalUrl = interactivePage.url();
await interactiveBrowser.close();
return {
success: true,
finalUrl,
authType: 'auto',
message: 'Manual login completed - session saved to profile',
};
}
// Detect auth type if auto
let authType = options.authType;
if (authType === 'auto') {
authType = await detectAuthType(page);
console.log(`Detected auth type: ${authType}`);
}
// Handle authentication based on type
let success = false;
switch (authType) {
case 'msal':
success = await handleMsalLogin(page, credentials, timeout);
break;
case 'form':
default:
success = await handleFormLogin(page, credentials, timeout);
break;
}
const finalUrl = page.url();
return {
success,
finalUrl,
authType,
message: success
? `Authentication successful - session saved to profile`
: 'Authentication failed',
};
} finally {
await browser.close();
}
}
// Navigate to authenticated page (handles auth if needed)
export async function navigateAuthenticated(
url: string,
options?: {
credentials?: { username: string; password: string };
headless?: boolean;
}
): Promise<{ page: Page; browser: BrowserContext }> {
const { page, browser } = await getPage({ headless: options?.headless ?? true });
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
// Check if we need to authenticate
if (!(await isAuthenticated(page, url))) {
console.log('Session expired or not authenticated. Attempting login...');
// Get credentials
const credentials = options?.credentials ?? getCredentials();
if (!credentials) {
throw new Error(
'Authentication required but no credentials provided. ' +
'Set CLOAKBROWSER_USERNAME and CLOAKBROWSER_PASSWORD environment variables.'
);
}
// Detect and handle auth
const authType = await detectAuthType(page);
let success = false;
if (authType === 'msal') {
success = await handleMsalLogin(page, credentials, 30000);
} else {
success = await handleFormLogin(page, credentials, 30000);
}
if (!success) {
await browser.close();
throw new Error('Authentication failed');
}
// Navigate back to original URL if we were redirected
if (!page.url().startsWith(url)) {
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
}
}
return { page, browser };
}
// CLI entry point
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'type', 'username', 'password'],
boolean: ['headless', 'help'],
default: {
type: 'auto',
headless: false, // Default to headed for auth so user can see/interact
},
alias: {
u: 'url',
t: 'type',
h: 'help',
},
});
if (args.help || !args.url) {
console.log(`
Web Authentication Handler
Usage:
npx tsx auth.ts --url <url> [options]
Options:
-u, --url <url> URL to authenticate (required)
-t, --type <type> Auth type: auto, form, or msal (default: auto)
--username <user> Username/email (or set CLOAKBROWSER_USERNAME env var)
--password <pass> Password (or set CLOAKBROWSER_PASSWORD env var)
--headless <bool> Run in headless mode (default: false for auth)
-h, --help Show this help message
Auth Types:
auto Auto-detect authentication type
form Generic username/password form
msal Microsoft SSO (login.microsoftonline.com)
Environment Variables:
CLOAKBROWSER_USERNAME Default username/email for authentication
CLOAKBROWSER_PASSWORD Default password for authentication
Examples:
# Interactive login (no credentials, opens browser)
npx tsx auth.ts --url "https://example.com/login"
# Form login with credentials
npx tsx auth.ts --url "https://example.com/login" --type form \\
--username "user@example.com" --password "secret"
# Microsoft SSO login
CLOAKBROWSER_USERNAME=user@company.com CLOAKBROWSER_PASSWORD=secret \\
npx tsx auth.ts --url "https://internal.company.com" --type msal
Notes:
- Session is saved to ~/.cloakbrowser-profile/ for persistence
- After successful auth, subsequent browses will be authenticated
- Use --headless false if you need to handle MFA manually
`);
process.exit(args.help ? 0 : 1);
}
const authType = args.type as AuthType;
if (!['auto', 'form', 'msal'].includes(authType)) {
console.error(`Invalid auth type: ${authType}. Must be auto, form, or msal.`);
process.exit(1);
}
try {
const result = await authenticate({
url: args.url,
authType,
credentials:
args.username && args.password
? { username: args.username, password: args.password }
: undefined,
headless: args.headless,
});
console.log(`\nAuthentication result:`);
console.log(` Success: ${result.success}`);
console.log(` Auth type: ${result.authType}`);
console.log(` Final URL: ${result.finalUrl}`);
console.log(` Message: ${result.message}`);
process.exit(result.success ? 0 : 1);
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
// Run if executed directly
const isMainModule = process.argv[1]?.includes('auth.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,152 @@
#!/usr/bin/env npx tsx
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
/**
* Browser launcher using CloakBrowser with persistent profile
*
* Usage:
* npx tsx browse.ts --url "https://example.com"
* npx tsx browse.ts --url "https://example.com" --screenshot --output page.png
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000
*/
import parseArgs from 'minimist';
import type { BrowserContext } from 'playwright-core';
import { getProfilePath, launchBrowser, getPage } from './lib/browser.js';
// Re-export shared helpers so existing imports of browse.ts continue to work.
export { getProfilePath, launchBrowser, getPage };
interface BrowseOptions {
url: string;
headless?: boolean;
screenshot?: boolean;
output?: string;
wait?: number;
timeout?: number;
interactive?: boolean;
}
interface BrowseResult {
title: string;
url: string;
screenshotPath?: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage();
try {
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, {
timeout: options.timeout ?? 60000,
waitUntil: 'domcontentloaded',
});
if (options.wait) {
console.log(`Waiting ${options.wait}ms...`);
await sleep(options.wait);
}
const result: BrowseResult = {
title: await page.title(),
url: page.url(),
};
console.log(`Page title: ${result.title}`);
console.log(`Final URL: ${result.url}`);
if (options.screenshot) {
const outputPath = options.output ?? 'screenshot.png';
await page.screenshot({ path: outputPath, fullPage: true });
result.screenshotPath = outputPath;
console.log(`Screenshot saved: ${outputPath}`);
}
if (options.interactive) {
console.log('\nInteractive mode - browser will stay open.');
console.log('Press Ctrl+C to close.');
await new Promise(() => {});
}
return result;
} finally {
if (!options.interactive) {
await browser.close();
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'],
boolean: ['screenshot', 'headless', 'interactive', 'help'],
default: {
headless: true,
screenshot: false,
interactive: false,
},
alias: {
u: 'url',
o: 'output',
s: 'screenshot',
h: 'help',
i: 'interactive',
},
});
if (args.help || !args.url) {
console.log(`
Web Browser with CloakBrowser
Usage:
npx tsx browse.ts --url <url> [options]
Options:
-u, --url <url> URL to navigate to (required)
-s, --screenshot Take a screenshot of the page
-o, --output <path> Output path for screenshot (default: screenshot.png)
--headless <bool> Run in headless mode (default: true)
--wait <ms> Wait time after page load in milliseconds
--timeout <ms> Navigation timeout (default: 60000)
-i, --interactive Keep browser open for manual interaction
-h, --help Show this help message
Examples:
npx tsx browse.ts --url "https://example.com"
npx tsx browse.ts --url "https://example.com" --screenshot --output page.png
npx tsx browse.ts --url "https://example.com" --headless false --interactive
Environment Variables:
CLOAKBROWSER_PROFILE_PATH Custom profile directory (default: ~/.cloakbrowser-profile/)
CLOAKBROWSER_HEADLESS Default headless mode (true/false)
`);
process.exit(args.help ? 0 : 1);
}
try {
await browse({
url: args.url,
headless: args.headless,
screenshot: args.screenshot,
output: args.output,
wait: args.wait ? parseInt(args.wait, 10) : undefined,
timeout: args.timeout ? parseInt(args.timeout, 10) : undefined,
interactive: args.interactive,
});
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
const isMainModule = process.argv[1]?.includes('browse.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,41 @@
#!/usr/bin/env node
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function fail(message, details) {
const payload = { error: message };
if (details) payload.details = details;
process.stderr.write(`${JSON.stringify(payload)}\n`);
process.exit(1);
}
async function main() {
try {
await import("cloakbrowser");
await import("playwright-core");
} catch (error) {
fail(
"Missing dependency/config: web-automation requires cloakbrowser and playwright-core.",
error instanceof Error ? error.message : String(error)
);
}
const browsePath = path.join(__dirname, "browse.ts");
const browseSource = fs.readFileSync(browsePath, "utf8");
if (!/launchPersistentContext/.test(browseSource) || !/from ['"]cloakbrowser['"]/.test(browseSource)) {
fail("browse.ts is not configured for CloakBrowser.");
}
process.stdout.write("OK: cloakbrowser + playwright-core installed\n");
process.stdout.write("OK: CloakBrowser integration detected in browse.ts\n");
}
main().catch((error) => {
fail("Install check failed.", error instanceof Error ? error.message : String(error));
});
@@ -0,0 +1,189 @@
#!/usr/bin/env node
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_WAIT_MS = 5000;
const MAX_WAIT_MS = 20000;
const NAV_TIMEOUT_MS = 30000;
const EXTRA_CHALLENGE_WAIT_MS = 8000;
const CONTENT_LIMIT = 12000;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function fail(message, details) {
const payload = { error: message };
if (details) payload.details = details;
process.stderr.write(`${JSON.stringify(payload)}\n`);
process.exit(1);
}
function parseWaitTime(raw) {
const value = Number.parseInt(raw || `${DEFAULT_WAIT_MS}`, 10);
if (!Number.isFinite(value) || value < 0) return DEFAULT_WAIT_MS;
return Math.min(value, MAX_WAIT_MS);
}
function parseTarget(rawUrl) {
if (!rawUrl) {
fail("Missing URL. Usage: node extract.js <URL>");
}
let parsed;
try {
parsed = new URL(rawUrl);
} catch (error) {
fail("Invalid URL.", error.message);
}
if (!["http:", "https:"].includes(parsed.protocol)) {
fail("Only http and https URLs are allowed.");
}
return parsed.toString();
}
function ensureParentDir(filePath) {
if (!filePath) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function detectChallenge(page) {
try {
return await page.evaluate(() => {
const text = (document.body?.innerText || "").toLowerCase();
return (
text.includes("checking your browser") ||
text.includes("just a moment") ||
text.includes("verify you are human") ||
text.includes("press and hold") ||
document.querySelector('iframe[src*="challenge"]') !== null ||
document.querySelector('iframe[src*="cloudflare"]') !== null
);
});
} catch {
return false;
}
}
async function loadCloakBrowser() {
try {
return await import("cloakbrowser");
} catch (error) {
fail(
"CloakBrowser is not installed for this skill. Run pnpm install in this skill's scripts directory first.",
error.message
);
}
}
async function runWithStderrLogs(fn) {
const originalLog = console.log;
const originalError = console.error;
console.log = (...args) => process.stderr.write(`${args.join(" ")}\n`);
console.error = (...args) => process.stderr.write(`${args.join(" ")}\n`);
try {
return await fn();
} finally {
console.log = originalLog;
console.error = originalError;
}
}
async function main() {
const requestedUrl = parseTarget(process.argv[2]);
const waitTime = parseWaitTime(process.env.WAIT_TIME);
const screenshotPath = process.env.SCREENSHOT_PATH || "";
const saveHtml = process.env.SAVE_HTML === "true";
const headless = process.env.HEADLESS !== "false";
const userAgent = process.env.USER_AGENT || undefined;
const startedAt = Date.now();
const { ensureBinary, launchContext } = await loadCloakBrowser();
let context;
try {
await runWithStderrLogs(() => ensureBinary());
context = await runWithStderrLogs(() => launchContext({
headless,
userAgent,
locale: "en-US",
viewport: { width: 1440, height: 900 },
humanize: true,
}));
const page = await context.newPage();
const response = await page.goto(requestedUrl, {
waitUntil: "domcontentloaded",
timeout: NAV_TIMEOUT_MS
});
await sleep(waitTime);
let challengeDetected = await detectChallenge(page);
if (challengeDetected) {
await sleep(EXTRA_CHALLENGE_WAIT_MS);
challengeDetected = await detectChallenge(page);
}
const extracted = await page.evaluate((contentLimit) => {
const bodyText = document.body?.innerText || "";
return {
finalUrl: window.location.href,
title: document.title || "",
content: bodyText.slice(0, contentLimit),
metaDescription:
document.querySelector('meta[name="description"]')?.content ||
document.querySelector('meta[property="og:description"]')?.content ||
""
};
}, CONTENT_LIMIT);
const result = {
requestedUrl,
finalUrl: extracted.finalUrl,
title: extracted.title,
content: extracted.content,
metaDescription: extracted.metaDescription,
status: response ? response.status() : null,
challengeDetected,
elapsedSeconds: ((Date.now() - startedAt) / 1000).toFixed(2)
};
if (screenshotPath) {
ensureParentDir(screenshotPath);
await page.screenshot({ path: screenshotPath, fullPage: false, timeout: 10000 });
result.screenshot = screenshotPath;
}
if (saveHtml) {
const htmlTarget = screenshotPath
? screenshotPath.replace(/\.[^.]+$/, ".html")
: path.resolve(__dirname, `page-${Date.now()}.html`);
ensureParentDir(htmlTarget);
fs.writeFileSync(htmlTarget, await page.content());
result.htmlFile = htmlTarget;
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
await context.close();
} catch (error) {
if (context) {
try {
await context.close();
} catch {
// Ignore close errors after the primary failure.
}
}
fail("Scrape failed.", error.message);
}
}
main();
@@ -0,0 +1,330 @@
#!/usr/bin/env npx tsx
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
import parseArgs from 'minimist';
import type { Page } from 'playwright-core';
import { launchBrowser } from './lib/browser.js';
type Step =
| { action: 'goto'; url: string }
| { action: 'click'; selector?: string; text?: string; role?: string; name?: string }
| { action: 'type'; selector?: string; text: string }
| { action: 'press'; key: string; selector?: string }
| { action: 'wait'; ms: number }
| { action: 'screenshot'; path: string }
| { action: 'extract'; selector: string; count?: number };
function normalizeNavigationUrl(rawUrl: string): string {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`Invalid navigation URL: ${rawUrl}`);
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Only http and https URLs are allowed in flow steps: ${rawUrl}`);
}
return parsed.toString();
}
function normalizeKey(k: string): string {
if (!k) return 'Enter';
const lower = k.toLowerCase();
if (lower === 'enter' || lower === 'return') return 'Enter';
if (lower === 'tab') return 'Tab';
if (lower === 'escape' || lower === 'esc') return 'Escape';
return k;
}
function splitInstructions(instruction: string): string[] {
return instruction
.split(/\bthen\b|;/gi)
.map((s) => s.trim())
.filter(Boolean);
}
function parseInstruction(instruction: string): Step[] {
const parts = splitInstructions(instruction);
const steps: Step[] = [];
for (const p of parts) {
// go to https://...
const goto = p.match(/^(?:go to|open|navigate to)\s+(https?:\/\/\S+)/i);
if (goto) {
steps.push({ action: 'goto', url: normalizeNavigationUrl(goto[1]) });
continue;
}
// click on "text" or click #selector or click button "name"
const clickRole = p.match(/^click\s+(button|link|textbox|img|image|tab)\s+"([^"]+)"$/i);
if (clickRole) {
const role = clickRole[1].toLowerCase() === 'image' ? 'img' : clickRole[1].toLowerCase();
steps.push({ action: 'click', role, name: clickRole[2] });
continue;
}
const clickText = p.match(/^click(?: on)?\s+"([^"]+)"/i);
if (clickText) {
steps.push({ action: 'click', text: clickText[1] });
continue;
}
const clickSelector = p.match(/^click(?: on)?\s+(#[\w-]+|\.[\w-]+|[a-z]+\[[^\]]+\])/i);
if (clickSelector) {
steps.push({ action: 'click', selector: clickSelector[1] });
continue;
}
// type "text" [in selector]
const typeInto = p.match(/^type\s+"([^"]+)"\s+in\s+(.+)$/i);
if (typeInto) {
steps.push({ action: 'type', text: typeInto[1], selector: typeInto[2].trim() });
continue;
}
const typeOnly = p.match(/^type\s+"([^"]+)"$/i);
if (typeOnly) {
steps.push({ action: 'type', text: typeOnly[1] });
continue;
}
// press enter [in selector]
const pressIn = p.match(/^press\s+(\w+)\s+in\s+(.+)$/i);
if (pressIn) {
steps.push({ action: 'press', key: normalizeKey(pressIn[1]), selector: pressIn[2].trim() });
continue;
}
const pressOnly = p.match(/^press\s+(\w+)$/i);
if (pressOnly) {
steps.push({ action: 'press', key: normalizeKey(pressOnly[1]) });
continue;
}
// wait 2s / wait 500ms
const waitS = p.match(/^wait\s+(\d+)\s*s(?:ec(?:onds?)?)?$/i);
if (waitS) {
steps.push({ action: 'wait', ms: parseInt(waitS[1], 10) * 1000 });
continue;
}
const waitMs = p.match(/^wait\s+(\d+)\s*ms$/i);
if (waitMs) {
steps.push({ action: 'wait', ms: parseInt(waitMs[1], 10) });
continue;
}
// screenshot path
const shot = p.match(/^screenshot(?: to)?\s+(.+)$/i);
if (shot) {
steps.push({ action: 'screenshot', path: shot[1].trim() });
continue;
}
throw new Error(`Could not parse step: "${p}"`);
}
return steps;
}
function validateSteps(steps: Step[]): Step[] {
return steps.map((step) =>
step.action === 'goto'
? {
...step,
url: normalizeNavigationUrl(step.url),
}
: step
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isLikelyLoginText(text: string): boolean {
return /(login|accedi|sign\s*in|entra)/i.test(text);
}
async function clickByText(page: Page, text: string): Promise<boolean> {
const patterns = [new RegExp(`^${escapeRegExp(text)}$`, 'i'), new RegExp(escapeRegExp(text), 'i')];
for (const pattern of patterns) {
const targets = [
page.getByRole('button', { name: pattern }).first(),
page.getByRole('link', { name: pattern }).first(),
page.getByText(pattern).first(),
];
for (const target of targets) {
if (await target.count()) {
try {
await target.click({ timeout: 8000 });
return true;
} catch {
// keep trying next candidate
}
}
}
}
return false;
}
async function fallbackLoginNavigation(page: Page, requestedText: string): Promise<boolean> {
if (!isLikelyLoginText(requestedText)) return false;
const current = new URL(page.url());
const candidateLinks = await page.evaluate(() => {
const loginTerms = ['login', 'accedi', 'sign in', 'entra'];
const anchors = Array.from(document.querySelectorAll('a[href], a[onclick], button[onclick]')) as Array<HTMLAnchorElement | HTMLButtonElement>;
return anchors
.map((el) => {
const text = (el.textContent || '').trim().toLowerCase();
const href = (el as HTMLAnchorElement).getAttribute('href') || '';
return { text, href };
})
.filter((x) => x.text && loginTerms.some((t) => x.text.includes(t)))
.map((x) => x.href)
.filter(Boolean);
});
// Prefer real URLs (not javascript:)
const realCandidate = candidateLinks.find((h) => /login|account\/login/i.test(h) && !h.startsWith('javascript:'));
if (realCandidate) {
const target = new URL(realCandidate, page.url()).toString();
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60000 });
return true;
}
// Site-specific fallback for Corriere
if (/corriere\.it$/i.test(current.hostname) || /\.corriere\.it$/i.test(current.hostname)) {
await page.goto('https://www.corriere.it/account/login', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
return true;
}
return false;
}
async function typeInBestTarget(page: Page, text: string, selector?: string) {
if (selector) {
await page.locator(selector).first().click({ timeout: 10000 });
await page.locator(selector).first().fill(text);
return;
}
const loc = page.locator('input[name="q"], input[type="search"], input[type="text"], textarea').first();
await loc.click({ timeout: 10000 });
await loc.fill(text);
}
async function pressOnTarget(page: Page, key: string, selector?: string) {
if (selector) {
await page.locator(selector).first().press(key);
return;
}
await page.keyboard.press(key);
}
async function runSteps(page: Page, steps: Step[]) {
for (const step of steps) {
switch (step.action) {
case 'goto':
await page.goto(normalizeNavigationUrl(step.url), {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
break;
case 'click':
if (step.selector) {
await page.locator(step.selector).first().click({ timeout: 15000 });
} else if (step.role && step.name) {
await page.getByRole(step.role as any, { name: new RegExp(escapeRegExp(step.name), 'i') }).first().click({ timeout: 15000 });
} else if (step.text) {
const clicked = await clickByText(page, step.text);
if (!clicked) {
const recovered = await fallbackLoginNavigation(page, step.text);
if (!recovered) {
throw new Error(`Could not click target text: ${step.text}`);
}
}
} else {
throw new Error('click step missing selector/text/role');
}
try {
await page.waitForLoadState('domcontentloaded', { timeout: 10000 });
} catch {
// no navigation is fine
}
break;
case 'type':
await typeInBestTarget(page, step.text, step.selector);
break;
case 'press':
await pressOnTarget(page, step.key, step.selector);
break;
case 'wait':
await page.waitForTimeout(step.ms);
break;
case 'screenshot':
await page.screenshot({ path: step.path, fullPage: true });
break;
case 'extract': {
const items = await page.locator(step.selector).allTextContents();
const out = items.slice(0, step.count ?? items.length).map((t) => t.trim()).filter(Boolean);
console.log(JSON.stringify(out, null, 2));
break;
}
default:
throw new Error('Unknown step');
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['instruction', 'steps'],
boolean: ['headless', 'help'],
default: { headless: true },
alias: { i: 'instruction', s: 'steps', h: 'help' },
});
if (args.help || (!args.instruction && !args.steps)) {
console.log(`
General Web Flow Runner (CloakBrowser)
Usage:
npx tsx flow.ts --instruction "go to https://example.com then type \"hello\" then press enter"
npx tsx flow.ts --steps '[{"action":"goto","url":"https://example.com"}]'
Supported natural steps:
- go to/open/navigate to <url>
- click on "Text"
- click <css-selector>
- type "text"
- type "text" in <css-selector>
- press <key>
- press <key> in <css-selector>
- wait <N>s | wait <N>ms
- screenshot <path>
`);
process.exit(args.help ? 0 : 1);
}
const steps = validateSteps(args.steps ? JSON.parse(args.steps) : parseInstruction(args.instruction));
const browser = await launchBrowser({ headless: args.headless });
const page = await browser.newPage();
try {
await runSteps(page, steps);
console.log('Flow complete. Final URL:', page.url());
} finally {
await browser.close();
}
}
main().catch((e) => {
console.error('Error:', e instanceof Error ? e.message : e);
process.exit(1);
});
@@ -0,0 +1,76 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
/**
* Shared browser-launch and profile helpers for web-automation scripts.
*
* Centralises the three reusable primitives that every command entry point
* needs:
* - getProfilePath() — resolve the persistent CloakBrowser profile dir
* - launchBrowser() — launch a CloakBrowser persistent context
* - getPage() — get a ready Page + BrowserContext pair
*
* All command entry points (auth.ts, browse.ts, flow.ts, scan-local-app.ts)
* import from here instead of duplicating these bodies.
*/
import { launchPersistentContext } from 'cloakbrowser';
import { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import type { BrowserContext, Page } from 'playwright-core';
/**
* Return the path to the persistent CloakBrowser profile directory.
*
* Uses `CLOAKBROWSER_PROFILE_PATH` env var when set; otherwise defaults to
* `~/.cloakbrowser-profile/` and creates it if it does not exist.
*/
export function getProfilePath(): string {
const customPath = process.env.CLOAKBROWSER_PROFILE_PATH;
if (customPath) return customPath;
const profileDir = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profileDir)) {
mkdirSync(profileDir, { recursive: true });
}
return profileDir;
}
/**
* Launch a CloakBrowser persistent context with the shared profile.
*
* Headless mode is resolved in order:
* 1. `options.headless` (explicit caller preference)
* 2. `CLOAKBROWSER_HEADLESS` env var
* 3. `true` (safe default)
*/
export async function launchBrowser(options: {
headless?: boolean;
}): Promise<BrowserContext> {
const profilePath = getProfilePath();
const envHeadless = process.env.CLOAKBROWSER_HEADLESS;
const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true);
console.log(`Using profile: ${profilePath}`);
console.log(`Headless mode: ${headless}`);
const context = await launchPersistentContext({
userDataDir: profilePath,
headless,
humanize: true,
});
return context;
}
/**
* Return a ready `{ page, browser }` pair using the shared persistent profile.
*
* Re-uses the first existing page or opens a new one if the context is empty.
*/
export async function getPage(options?: {
headless?: boolean;
}): Promise<{ page: Page; browser: BrowserContext }> {
const browser = await launchBrowser({ headless: options?.headless });
const page = browser.pages()[0] || (await browser.newPage());
return { page, browser };
}
@@ -0,0 +1,37 @@
{
"name": "@ai-coding-skills/web-automation-pi-mirror",
"version": "1.0.0",
"description": "Web browsing and scraping scripts using CloakBrowser",
"type": "module",
"scripts": {
"check-install": "node check-install.js",
"extract": "node extract.js",
"browse": "tsx browse.ts",
"auth": "tsx auth.ts",
"flow": "tsx flow.ts",
"scrape": "tsx scrape.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "pnpm run typecheck && node --check check-install.js && node --check extract.js",
"fetch-browser": "npx cloakbrowser install"
},
"dependencies": {
"@mozilla/readability": "^0.5.0",
"better-sqlite3": "^12.6.2",
"cloakbrowser": "^0.3.22",
"jsdom": "^24.0.0",
"minimist": "^1.2.8",
"playwright-core": "^1.59.1",
"turndown": "^7.1.2",
"turndown-plugin-gfm": "^1.0.2"
},
"devDependencies": {
"@types/jsdom": "^21.1.6",
"@types/minimist": "^1.2.5",
"@types/turndown": "^5.0.4",
"esbuild": "0.27.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"private": true
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
#!/usr/bin/env npx tsx
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
import type { Page } from 'playwright-core';
import { getPage } from './lib/browser.js';
type NavResult = {
requestedUrl: string;
url: string;
status: number | null;
title: string;
error?: string;
};
type RouteCheck = {
route: string;
result: NavResult;
heading: string | null;
};
const DEFAULT_BASE_URL = 'http://localhost:3000';
const DEFAULT_REPORT_PATH = resolve(process.cwd(), 'scan-local-app.md');
function env(name: string): string | undefined {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
function getRoutes(baseUrl: string): string[] {
const routeList = env('SCAN_ROUTES');
if (routeList) {
return routeList
.split(',')
.map((route) => route.trim())
.filter(Boolean)
.map((route) => new URL(route, baseUrl).toString());
}
return [baseUrl];
}
type GotoError = { error: unknown };
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
.catch((error: unknown): GotoError => ({ error }));
if (response !== null && response !== undefined && 'error' in response) {
const gotoError = response as GotoError;
return {
requestedUrl: url,
url: page.url(),
status: null,
title: await page.title().catch(() => ''),
error: String(gotoError.error),
};
}
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
return {
requestedUrl: url,
url: page.url(),
status: httpResponse ? httpResponse.status() : null,
title: await page.title().catch(() => ''),
};
}
async function textOrNull(page: Page, selector: string): Promise<string | null> {
const locator = page.locator(selector).first();
try {
if ((await locator.count()) === 0) return null;
const value = await locator.textContent();
return value ? value.trim().replace(/\s+/g, ' ') : null;
} catch {
return null;
}
}
async function loginIfConfigured(page: Page, baseUrl: string, lines: string[]) {
const loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
const usernameSelector = env('SCAN_USERNAME_SELECTOR') ?? 'input[type="email"], input[name="email"]';
const passwordSelector = env('SCAN_PASSWORD_SELECTOR') ?? 'input[type="password"], input[name="password"]';
const submitSelector = env('SCAN_SUBMIT_SELECTOR') ?? 'button[type="submit"], input[type="submit"]';
if (!loginPath) {
lines.push('## Login');
lines.push('- Skipped: set `SCAN_LOGIN_PATH` to enable login smoke checks.');
lines.push('');
return;
}
const loginUrl = new URL(loginPath, baseUrl).toString();
lines.push('## Login');
lines.push(`- Login URL: ${loginUrl}`);
await gotoWithStatus(page, loginUrl);
if (!username || !password) {
lines.push('- Skipped: set `SCAN_USERNAME`/`SCAN_PASSWORD` or `CLOAKBROWSER_USERNAME`/`CLOAKBROWSER_PASSWORD`.');
lines.push('');
return;
}
await page.locator(usernameSelector).first().fill(username);
await page.locator(passwordSelector).first().fill(password);
await page.locator(submitSelector).first().click();
await page.waitForTimeout(2500);
lines.push(`- After submit URL: ${page.url()}`);
lines.push(`- Cookie count: ${(await page.context().cookies()).length}`);
lines.push('');
}
async function checkRoutes(page: Page, baseUrl: string, lines: string[]) {
const routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = [];
for (const url of routes) {
const result = await gotoWithStatus(page, url);
const heading = await textOrNull(page, 'h1');
routeChecks.push({
route: url,
result,
heading,
});
}
lines.push('## Route Checks');
for (const check of routeChecks) {
const relativeUrl = check.route.startsWith(baseUrl) ? check.route.slice(baseUrl.length) || '/' : check.route;
const finalPath = check.result.url.startsWith(baseUrl)
? check.result.url.slice(baseUrl.length) || '/'
: check.result.url;
const suffix = check.heading ? `, h1="${check.heading}"` : '';
const errorSuffix = check.result.error ? `, error="${check.result.error}"` : '';
lines.push(
`- ${relativeUrl} → status ${check.result.status ?? 'ERR'} (final ${finalPath})${suffix}${errorSuffix}`
);
}
lines.push('');
}
async function main() {
const baseUrl = env('SCAN_BASE_URL') ?? DEFAULT_BASE_URL;
const reportPath = resolve(env('SCAN_REPORT_PATH') ?? DEFAULT_REPORT_PATH);
const headless = (env('SCAN_HEADLESS') ?? env('CLOAKBROWSER_HEADLESS') ?? 'true') === 'true';
const { page, browser } = await getPage({ headless });
const lines: string[] = [];
lines.push('# Web Automation Scan (local)');
lines.push('');
lines.push(`- Base URL: ${baseUrl}`);
lines.push(`- Timestamp: ${new Date().toISOString()}`);
lines.push(`- Headless: ${headless}`);
lines.push(`- Report Path: ${reportPath}`);
lines.push('');
try {
await loginIfConfigured(page, baseUrl, lines);
await checkRoutes(page, baseUrl, lines);
lines.push('## Notes');
lines.push('- This generic smoke helper records route availability and top-level headings for a local app.');
lines.push('- Configure login and route coverage with `SCAN_*` environment variables.');
} finally {
await browser.close();
}
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf-8');
console.log(`Report written to ${reportPath}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,352 @@
#!/usr/bin/env npx tsx
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
/**
* Web scraper that extracts content to markdown
*
* Usage:
* npx tsx scrape.ts --url "https://example.com" --mode main
* npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
* npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".content"
*/
import TurndownService from 'turndown';
import * as turndownPluginGfm from 'turndown-plugin-gfm';
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
import { writeFileSync } from 'fs';
import parseArgs from 'minimist';
import { getPage } from './browse.js';
// Types
type ScrapeMode = 'main' | 'full' | 'selector';
interface ScrapeOptions {
url: string;
mode: ScrapeMode;
selector?: string;
output?: string;
includeLinks?: boolean;
includeTables?: boolean;
includeImages?: boolean;
headless?: boolean;
wait?: number;
}
interface ScrapeResult {
title: string;
url: string;
markdown: string;
byline?: string;
excerpt?: string;
}
// Configure Turndown for markdown conversion
function createTurndownService(options: {
includeLinks?: boolean;
includeTables?: boolean;
includeImages?: boolean;
}): TurndownService {
const turndown = new TurndownService({
headingStyle: 'atx',
hr: '---',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
fence: '```',
emDelimiter: '*',
strongDelimiter: '**',
linkStyle: 'inlined',
});
// Add GFM support (tables, strikethrough, task lists)
turndown.use(turndownPluginGfm.gfm);
// Custom rule for code blocks with language detection
turndown.addRule('codeBlockWithLanguage', {
filter: (node) => {
return (
node.nodeName === 'PRE' &&
node.firstChild?.nodeName === 'CODE'
);
},
replacement: (_content, node) => {
const codeNode = node.firstChild as HTMLElement;
const className = codeNode.getAttribute('class') || '';
const langMatch = className.match(/language-(\w+)/);
const lang = langMatch ? langMatch[1] : '';
const code = codeNode.textContent || '';
return `\n\n\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
},
});
// Remove images if not included
if (!options.includeImages) {
turndown.addRule('removeImages', {
filter: 'img',
replacement: () => '',
});
}
// Remove links but keep text if not included
if (!options.includeLinks) {
turndown.addRule('removeLinks', {
filter: 'a',
replacement: (content) => content,
});
}
// Remove script, style, nav, footer, aside elements
turndown.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
return turndown;
}
// Extract main content using Readability
function extractMainContent(html: string, url: string): {
content: string;
title: string;
byline?: string;
excerpt?: string;
} {
const dom = new JSDOM(html, { url });
const reader = new Readability(dom.window.document);
const article = reader.parse();
if (!article) {
throw new Error('Could not extract main content from page');
}
return {
content: article.content,
title: article.title,
byline: article.byline || undefined,
excerpt: article.excerpt || undefined,
};
}
// Scrape a URL and return markdown
export async function scrape(options: ScrapeOptions): Promise<ScrapeResult> {
const { page, browser } = await getPage({ headless: options.headless ?? true });
try {
// Navigate to URL
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, {
timeout: 60000,
waitUntil: 'domcontentloaded',
});
// Wait if specified
if (options.wait) {
console.log(`Waiting ${options.wait}ms for dynamic content...`);
await page.waitForTimeout(options.wait);
}
const pageTitle = await page.title();
const pageUrl = page.url();
let html: string;
let title = pageTitle;
let byline: string | undefined;
let excerpt: string | undefined;
// Get HTML based on mode
switch (options.mode) {
case 'main': {
// Get full page HTML and extract with Readability
const fullHtml = await page.content();
const extracted = extractMainContent(fullHtml, pageUrl);
html = extracted.content;
title = extracted.title || pageTitle;
byline = extracted.byline;
excerpt = extracted.excerpt;
break;
}
case 'selector': {
if (!options.selector) {
throw new Error('Selector mode requires --selector option');
}
const element = await page.$(options.selector);
if (!element) {
throw new Error(`Selector not found: ${options.selector}`);
}
html = await element.innerHTML();
break;
}
case 'full':
default: {
// Get body content, excluding common non-content elements
html = await page.evaluate(() => {
// Remove common non-content elements
const selectorsToRemove = [
'script', 'style', 'noscript', 'iframe',
'nav', 'header', 'footer', '.cookie-banner',
'.advertisement', '.ads', '#ads', '.social-share',
'.comments', '#comments', '.sidebar'
];
selectorsToRemove.forEach(selector => {
document.querySelectorAll(selector).forEach(el => el.remove());
});
return document.body.innerHTML;
});
break;
}
}
// Convert to markdown
const turndown = createTurndownService({
includeLinks: options.includeLinks ?? true,
includeTables: options.includeTables ?? true,
includeImages: options.includeImages ?? false,
});
let markdown = turndown.turndown(html);
// Add title as H1 if not already present
if (!markdown.startsWith('# ')) {
markdown = `# ${title}\n\n${markdown}`;
}
// Add metadata header
const metadataLines = [
`<!-- Scraped from: ${pageUrl} -->`,
byline ? `<!-- Author: ${byline} -->` : null,
excerpt ? `<!-- Excerpt: ${excerpt} -->` : null,
`<!-- Scraped at: ${new Date().toISOString()} -->`,
'',
].filter(Boolean);
markdown = metadataLines.join('\n') + '\n' + markdown;
// Clean up excessive whitespace
markdown = markdown
.replace(/\n{4,}/g, '\n\n\n')
.replace(/[ \t]+$/gm, '')
.trim();
const result: ScrapeResult = {
title,
url: pageUrl,
markdown,
byline,
excerpt,
};
// Save to file if output specified
if (options.output) {
writeFileSync(options.output, markdown, 'utf-8');
console.log(`Markdown saved to: ${options.output}`);
}
return result;
} finally {
await browser.close();
}
}
// CLI entry point
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'mode', 'selector', 'output'],
boolean: ['headless', 'links', 'tables', 'images', 'help'],
default: {
mode: 'main',
headless: true,
links: true,
tables: true,
images: false,
},
alias: {
u: 'url',
m: 'mode',
s: 'selector',
o: 'output',
h: 'help',
},
});
if (args.help || !args.url) {
console.log(`
Web Scraper - Extract content to Markdown
Usage:
npx tsx scrape.ts --url <url> [options]
Options:
-u, --url <url> URL to scrape (required)
-m, --mode <mode> Scrape mode: main, full, or selector (default: main)
-s, --selector <sel> CSS selector for selector mode
-o, --output <path> Output file path for markdown
--headless <bool> Run in headless mode (default: true)
--wait <ms> Wait time for dynamic content
--links Include links in output (default: true)
--tables Include tables in output (default: true)
--images Include images in output (default: false)
-h, --help Show this help message
Scrape Modes:
main Extract main article content using Readability (best for articles)
full Full page content with common elements removed
selector Extract specific element by CSS selector
Examples:
npx tsx scrape.ts --url "https://docs.example.com/guide" --mode main
npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".api-docs"
npx tsx scrape.ts --url "https://example.com" --mode main --no-links --output clean.md
Output Format:
- GitHub Flavored Markdown (tables, strikethrough, task lists)
- Proper heading hierarchy
- Code blocks with language detection
- Metadata comments at top (source URL, date)
`);
process.exit(args.help ? 0 : 1);
}
const mode = args.mode as ScrapeMode;
if (!['main', 'full', 'selector'].includes(mode)) {
console.error(`Invalid mode: ${mode}. Must be main, full, or selector.`);
process.exit(1);
}
try {
const result = await scrape({
url: args.url,
mode,
selector: args.selector,
output: args.output,
includeLinks: args.links,
includeTables: args.tables,
includeImages: args.images,
headless: args.headless,
wait: args.wait ? parseInt(args.wait, 10) : undefined,
});
// Print result summary
console.log(`\nScrape complete:`);
console.log(` Title: ${result.title}`);
console.log(` URL: ${result.url}`);
if (result.byline) console.log(` Author: ${result.byline}`);
console.log(` Markdown length: ${result.markdown.length} chars`);
// Print markdown if not saved to file
if (!args.output) {
console.log('\n--- Markdown Output ---\n');
console.log(result.markdown);
}
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
// Run if executed directly
const isMainModule = process.argv[1]?.includes('scrape.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,37 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync, existsSync } from 'fs';
async function test() {
const profilePath = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profilePath)) {
mkdirSync(profilePath, { recursive: true });
}
console.log('Profile path:', profilePath);
console.log('Launching CloakBrowser with full options...');
const browser = await launchPersistentContext({
headless: true,
userDataDir: profilePath,
humanize: true,
});
console.log('Browser launched');
const page = browser.pages()[0] || await browser.newPage();
console.log('Page created');
await page.goto('https://github.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await page.screenshot({ path: '/tmp/github-test.png' });
console.log('Screenshot saved');
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,24 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
import { launch } from 'cloakbrowser';
async function test() {
console.log('Launching CloakBrowser with minimal config...');
const browser = await launch({
headless: true,
humanize: true,
});
console.log('Browser launched');
const page = await browser.newPage();
console.log('Page created');
await page.goto('https://example.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,34 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync, existsSync } from 'fs';
async function test() {
const profilePath = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profilePath)) {
mkdirSync(profilePath, { recursive: true });
}
console.log('Profile path:', profilePath);
console.log('Launching with persistent userDataDir...');
const browser = await launchPersistentContext({
headless: true,
userDataDir: profilePath,
humanize: true,
});
console.log('Browser launched');
const page = browser.pages()[0] || await browser.newPage();
console.log('Page created');
await page.goto('https://example.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,9 @@
// ⚠️ GENERATED FILE do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
declare module 'turndown-plugin-gfm' {
import TurndownService from 'turndown';
export function gfm(turndownService: TurndownService): void;
export function strikethrough(turndownService: TurndownService): void;
export function tables(turndownService: TurndownService): void;
export function taskListItems(turndownService: TurndownService): void;
}
+3404
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
# pnpm workspace — includes canonical sources + uniquely-named generated variants (M3)
#
# M3 update: every generated agent-variant package.json now carries a unique
# private name (@ai-coding-skills/<skill>-<agent>), which allows pnpm to include
# ALL generated roots alongside the canonical sources. The M1 negative-glob
# exclusions are replaced by explicit positive includes.
#
# Canonical source packages (never generated):
# - skills/atlassian/shared/scripts → atlassian-skill-scripts (source + tests)
# - skills/web-automation/shared → web-automation-scripts (source template)
#
# Generated agent-variant directories (now uniquely named, included):
# - skills/atlassian/{claude-code,codex,cursor,opencode,pi}/scripts
# - skills/web-automation/{claude-code,codex,cursor,opencode,pi}/scripts
# - pi-package/skills/atlassian/scripts (@ai-coding-skills/atlassian-pi-mirror)
# - pi-package/skills/web-automation/scripts (@ai-coding-skills/web-automation-pi-mirror)
packages:
# ── Canonical source packages ────────────────────────────────────────────
- "skills/atlassian/shared/scripts"
- "skills/web-automation/shared"
# ── Generated atlassian agent-variant packages ───────────────────────────
- "skills/atlassian/claude-code/scripts"
- "skills/atlassian/codex/scripts"
- "skills/atlassian/cursor/scripts"
- "skills/atlassian/opencode/scripts"
- "skills/atlassian/pi/scripts"
# ── Generated web-automation agent-variant packages ──────────────────────
- "skills/web-automation/claude-code/scripts"
- "skills/web-automation/codex/scripts"
- "skills/web-automation/cursor/scripts"
- "skills/web-automation/opencode/scripts"
- "skills/web-automation/pi/scripts"
# ── Generated pi-package mirrors ─────────────────────────────────────────
- "pi-package/skills/atlassian/scripts"
- "pi-package/skills/web-automation/scripts"
+616
View File
@@ -0,0 +1,616 @@
#!/usr/bin/env node
/**
* generate-skills.mjs shared-source generator for agent variants (M3, S-302)
*
* Generates every agent-variant directory (`skills/<skill>/<agent>/`) and
* `pi-package/skills/<skill>/` mirror from canonical sources. Generated files
* carry file-type-aware headers; each generated root gets a non-self-referential
* `.generated-manifest.json`.
*
* Usage:
* node scripts/generate-skills.mjs # regenerate everything
* pnpm run sync:pi # same via pnpm alias
*
* Exported helpers (used by verify-generated.mjs and tests):
* detectFileType(filePath) string
* applyHeader(content, fileType, canonicalHint) string
* makePackageJsonContent(sourcePkg, skillName, agentName) object
* getGeneratedRoots(repoRoot?) string[]
* buildManifest(generatedRootAbs, generatedRootRel) Promise<object>
* generateSkills(repoRoot, options?) Promise<{generatedRoots: string[]}>
*/
import {
lstat,
mkdir,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import crypto from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
// ── Constants ──────────────────────────────────────────────────────────────
const MANIFEST_SCHEMA =
"https://ai-coding-skills.dev/schemas/generated-manifest/v1.json";
const MANIFEST_GENERATOR = "scripts/generate-skills.mjs";
const MANIFEST_FILENAME = ".generated-manifest.json";
const AGENTS = ["claude-code", "codex", "cursor", "opencode", "pi"];
/**
* Canonical list of all generated roots, relative to repo root.
* Verifier uses this to know which directories to walk.
*/
const GENERATED_ROOTS = [
// atlassian agent variants
"skills/atlassian/claude-code",
"skills/atlassian/codex",
"skills/atlassian/cursor",
"skills/atlassian/opencode",
"skills/atlassian/pi",
// web-automation agent variants
"skills/web-automation/claude-code",
"skills/web-automation/codex",
"skills/web-automation/cursor",
"skills/web-automation/opencode",
"skills/web-automation/pi",
// create-plan agent variants
"skills/create-plan/claude-code",
"skills/create-plan/codex",
"skills/create-plan/cursor",
"skills/create-plan/opencode",
"skills/create-plan/pi",
// do-task agent variants
"skills/do-task/claude-code",
"skills/do-task/codex",
"skills/do-task/cursor",
"skills/do-task/opencode",
"skills/do-task/pi",
// implement-plan agent variants
"skills/implement-plan/claude-code",
"skills/implement-plan/codex",
"skills/implement-plan/cursor",
"skills/implement-plan/opencode",
"skills/implement-plan/pi",
// reviewer-runtime pi variant
"skills/reviewer-runtime/pi",
// pi-package mirrors
"pi-package/skills/atlassian",
"pi-package/skills/create-plan",
"pi-package/skills/do-task",
"pi-package/skills/implement-plan",
"pi-package/skills/web-automation",
];
// ── File-type detection ────────────────────────────────────────────────────
/**
* Classify a file path into a header policy category.
* @param {string} filePath - Relative or absolute path to the file.
* @returns {'markdown'|'shell'|'ts'|'js'|'json'|'yaml'|'jsonc'|'unknown'}
*/
export function detectFileType(filePath) {
const base = path.basename(filePath);
const ext = path.extname(base).toLowerCase();
if (ext === ".md") return "markdown";
if (ext === ".sh") return "shell";
if (ext === ".ts") return "ts";
if (ext === ".js") return "js";
if (ext === ".jsonc") return "jsonc";
if (ext === ".json") return "json";
if (ext === ".yaml" || ext === ".yml") return "yaml";
return "unknown";
}
// ── Header insertion ──────────────────────────────────────────────────────
const HEADER_MSG = (hint) =>
`⚠️ GENERATED FILE do not edit directly. Edit the canonical source in ${hint} and run \`pnpm run sync:pi\`.`;
/**
* Insert a file-type-aware generated-file header into content.
*
* Policy:
* markdown HTML comment after YAML front matter (or at top if no front matter)
* shell # comment after shebang (never before it)
* ts/js // comment at top
* jsonc // comment at top
* yaml # comment at top (pnpm-lock.yaml skipped by caller)
* json no header (recorded in .generated-manifest.json)
* unknown no header
*
* @param {string} content - Original file content.
* @param {string} fileType - Output of detectFileType().
* @param {string} canonicalHint - Human-readable hint to canonical source location.
* @returns {string} Content with header inserted, or original if no header applies.
*/
export function applyHeader(content, fileType, canonicalHint) {
const msg = HEADER_MSG(canonicalHint);
switch (fileType) {
case "markdown": {
// Insert HTML comment after YAML front matter closing ---, or at top
if (content.startsWith("---\n")) {
const closingIdx = content.indexOf("\n---\n", 4);
if (closingIdx !== -1) {
const after = closingIdx + 5; // length of "\n---\n"
const before = content.slice(0, after);
const rest = content.slice(after);
// Ensure the comment is on its own line, with a blank line before/after
return `${before}\n<!-- ${msg} -->\n${rest}`;
}
}
return `<!-- ${msg} -->\n${content}`;
}
case "shell": {
// Insert # comment AFTER shebang line (never before it)
const lines = content.split("\n");
if (lines[0].startsWith("#!")) {
return [lines[0], `# ${msg}`, ...lines.slice(1)].join("\n");
}
return `# ${msg}\n${content}`;
}
case "ts":
case "js":
case "jsonc": {
// Insert after shebang if present (TypeScript requires #! on line 1)
const lines = content.split("\n");
if (lines[0].startsWith("#!")) {
return [lines[0], `// ${msg}`, ...lines.slice(1)].join("\n");
}
return `// ${msg}\n${content}`;
}
case "yaml": {
return `# ${msg}\n${content}`;
}
case "json":
case "unknown":
default:
return content;
}
}
// ── package.json transformation ───────────────────────────────────────────
/**
* Produce a modified package.json object with a unique scoped name and
* `"private": true` for an agent-variant generated root.
*
* @param {object} sourcePkg - Parsed source package.json object.
* @param {string} skillName - Skill identifier (e.g. "atlassian").
* @param {string} agentName - Agent identifier (e.g. "claude-code").
* @returns {object} New object (source is not mutated).
*/
export function makePackageJsonContent(sourcePkg, skillName, agentName) {
return {
...sourcePkg,
name: `@ai-coding-skills/${skillName}-${agentName}`,
private: true,
};
}
// ── Generated-root list ───────────────────────────────────────────────────
/**
* Return the authoritative list of generated roots as repo-relative paths.
* Callers (verify-generated.mjs) use this to know which directories to walk.
*
* @returns {string[]} Sorted array of relative paths.
*/
export function getGeneratedRoots() {
return [...GENERATED_ROOTS];
}
// ── Manifest construction ─────────────────────────────────────────────────
/**
* Walk a directory recursively and return all file paths (abs).
* Skips node_modules.
*/
async function walkDir(dir) {
const results = [];
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (entry.name === "node_modules") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const sub = await walkDir(full);
results.push(...sub);
} else if (entry.isFile()) {
results.push(full);
}
}
return results;
}
/**
* Build a .generated-manifest.json object for a generated root.
*
* The manifest lists every file in the generated root EXCEPT itself.
* Files are sorted by relative path for stable canonical serialization.
*
* @param {string} generatedRootAbs - Absolute path to the generated root directory.
* @param {string} generatedRootRel - Repo-relative path (e.g. "skills/create-plan/pi").
* @returns {Promise<object>} Manifest object (not yet serialized to disk).
*/
export async function buildManifest(generatedRootAbs, generatedRootRel) {
const allFiles = await walkDir(generatedRootAbs);
const entries = [];
for (const absPath of allFiles) {
const relPath = path.relative(generatedRootAbs, absPath).replace(/\\/g, "/");
// Non-self-referential: the manifest never lists itself
if (relPath === MANIFEST_FILENAME) continue;
const contentBuf = await readFile(absPath);
const sha256 = crypto.createHash("sha256").update(contentBuf).digest("hex");
const st = await lstat(absPath);
const mode = (st.mode & 0o777).toString(8).padStart(3, "0");
entries.push({
path: relPath,
kind: "file",
mode,
sha256,
});
}
entries.sort((a, b) => a.path.localeCompare(b.path));
return {
$schema: MANIFEST_SCHEMA,
generator: MANIFEST_GENERATOR,
generatedRoot: generatedRootRel,
files: entries,
};
}
// ── Core generation helpers ───────────────────────────────────────────────
/**
* Copy a single file from source to destination, inserting a header.
* Skips header for:
* - pnpm-lock.yaml (managed by pnpm, would be stripped on next install)
* - JSON files (per header policy, no in-file header)
* - node_modules (never copied)
*/
async function copyWithHeader(srcAbs, dstAbs, canonicalHint) {
const basename = path.basename(srcAbs);
// Skip pnpm-lock.yaml header (pnpm regenerates without comment)
const skipHeader = basename === "pnpm-lock.yaml";
const raw = await readFile(srcAbs, "utf8");
const fileType = skipHeader ? "unknown" : detectFileType(srcAbs);
const content = applyHeader(raw, fileType, canonicalHint);
await mkdir(path.dirname(dstAbs), { recursive: true });
await writeFile(dstAbs, content, "utf8");
}
/**
* Recursively copy a directory tree, adding headers to text files.
*/
async function copyDirWithHeaders(srcDir, dstDir, canonicalHint) {
const entries = await readdir(srcDir, { withFileTypes: true });
await mkdir(dstDir, { recursive: true });
for (const entry of entries) {
if (entry.name === "node_modules") continue;
const src = path.join(srcDir, entry.name);
const dst = path.join(dstDir, entry.name);
if (entry.isDirectory()) {
await copyDirWithHeaders(src, dst, canonicalHint);
} else if (entry.isFile()) {
await copyWithHeader(src, dst, canonicalHint);
}
}
}
/**
* Write a .generated-manifest.json file into a generated root.
*/
async function writeManifest(generatedRootAbs, generatedRootRel) {
const manifest = await buildManifest(generatedRootAbs, generatedRootRel);
const dstPath = path.join(generatedRootAbs, MANIFEST_FILENAME);
await writeFile(dstPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
}
// ── Skill-family generators ───────────────────────────────────────────────
/**
* Generate one agent variant for a "skills-only" skill
* (create-plan, do-task, implement-plan).
*
* Canonical source: skills/<skill>/_source/<agent>/
* Generated root: skills/<skill>/<agent>/
*
* @param {string} writeRoot - Root directory to write output into (defaults to repoRoot).
*/
async function generateSkillOnlyVariant(repoRoot, writeRoot, skillName, agentName, generatedRootRel) {
const sourceDir = path.join(repoRoot, "skills", skillName, "_source", agentName);
const targetDir = path.join(writeRoot, generatedRootRel);
const canonicalHint = `skills/${skillName}/_source/${agentName}/`;
// Clear previous generated content (preserve node_modules if any)
await clearGeneratedRoot(targetDir);
// Copy all files from source with headers
await copyDirWithHeaders(sourceDir, targetDir, canonicalHint);
// Write manifest
await writeManifest(targetDir, generatedRootRel);
}
/**
* Generate one agent variant for a "scripts+skill" skill
* (atlassian, web-automation).
*
* For atlassian:
* - SKILL.md from skills/atlassian/_source/<agent>/SKILL.md
* - scripts/* from skills/atlassian/shared/scripts/
* (only src/, tsconfig.json, pnpm-lock.yaml not tests/ or scripts/sync-*)
*
* For web-automation:
* - SKILL.md from skills/web-automation/_source/<agent>/SKILL.md
* - scripts/* from skills/web-automation/shared/
*
* @param {string} [packageAgentName] - Override the agent name used only for the
* package.json `name` field. Defaults to `agentName`. Use this to give
* pi-package mirrors a distinct name (e.g. "pi-mirror") so workspace package
* names are unique even when two roots share the same source agent.
*/
async function generateScriptsSkillVariant(
repoRoot,
writeRoot,
skillName,
agentName,
generatedRootRel,
config,
packageAgentName,
) {
const targetDir = path.join(writeRoot, generatedRootRel);
await clearGeneratedRoot(targetDir);
// 1. Copy SKILL.md from per-agent canonical source
const skillMdSrc = path.join(repoRoot, "skills", skillName, "_source", agentName, "SKILL.md");
const skillMdDst = path.join(targetDir, "SKILL.md");
const skillMdHint = `skills/${skillName}/_source/${agentName}/SKILL.md`;
await copyWithHeader(skillMdSrc, skillMdDst, skillMdHint);
// 2. Copy scripts
const scriptsTargetDir = path.join(targetDir, "scripts");
await mkdir(scriptsTargetDir, { recursive: true });
const canonicalScripts = path.join(repoRoot, config.canonicalScripts);
const scriptsHint = `${config.canonicalScripts}/`;
for (const entry of config.scriptFiles) {
const srcPath = path.join(canonicalScripts, entry);
const dstPath = path.join(scriptsTargetDir, entry); // scriptsTargetDir already uses writeRoot
// Check if source exists
let st;
try {
st = await lstat(srcPath);
} catch {
continue; // skip if file doesn't exist in canonical
}
if (st.isDirectory()) {
await copyDirWithHeaders(srcPath, dstPath, scriptsHint);
} else {
await copyWithHeader(srcPath, dstPath, scriptsHint);
}
}
// 3. Generate modified package.json
const srcPkgPath = path.join(canonicalScripts, "package.json");
const srcPkg = JSON.parse(await readFile(srcPkgPath, "utf8"));
let targetPkg = makePackageJsonContent(srcPkg, skillName, packageAgentName ?? agentName);
// For atlassian variants, strip test/sync scripts (not in agent variants)
if (skillName === "atlassian") {
const agentScripts = {};
for (const [k, v] of Object.entries(targetPkg.scripts ?? {})) {
if (k !== "test" && k !== "sync:agents") agentScripts[k] = v;
}
targetPkg = { ...targetPkg, scripts: agentScripts };
}
const dstPkgPath = path.join(scriptsTargetDir, "package.json");
await writeFile(dstPkgPath, JSON.stringify(targetPkg, null, 2) + "\n", "utf8");
// 4. Write manifest
await writeManifest(targetDir, generatedRootRel);
}
/**
* Generate the reviewer-runtime pi variant from the non-Pi canonical scripts.
*
* Canonical source: skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}
* Generated root: skills/reviewer-runtime/pi/
*
* The pi variant is byte-identical to the canonical except for:
* - A generated # comment after the shebang (replaces old "keep in sync" comment)
*
* @param {string} writeRoot - Root directory to write output into (defaults to repoRoot).
*/
async function generateReviewerRuntimePi(repoRoot, writeRoot) {
const srcDir = path.join(repoRoot, "skills", "reviewer-runtime");
const dstDir = path.join(writeRoot, "skills", "reviewer-runtime", "pi");
const canonicalHint = "skills/reviewer-runtime/";
// Clear old generated content (preserve tests/ which is canonical)
await clearGeneratedRoot(dstDir);
for (const fname of ["run-review.sh", "notify-telegram.sh"]) {
const srcPath = path.join(srcDir, fname);
const dstPath = path.join(dstDir, fname);
const raw = await readFile(srcPath, "utf8");
let content = applyHeader(raw, "shell", `${canonicalHint}${fname}`);
await mkdir(path.dirname(dstPath), { recursive: true });
await writeFile(dstPath, content, "utf8");
// Preserve executable bit
const st = await lstat(srcPath);
if (st.mode & 0o100) {
const { chmod } = await import("node:fs/promises");
await chmod(dstPath, 0o755);
}
}
// Write manifest (generatedRootRel is always relative to repo root, not writeRoot)
await writeManifest(dstDir, "skills/reviewer-runtime/pi");
}
/**
* Clear generated content in a root, preserving:
* - node_modules (installed by pnpm) at any depth
* - .generated-manifest.json (will be rewritten after generation)
*
* Subdirectories are always recursed into before removal so that
* node_modules trees nested at any depth (e.g. scripts/node_modules inside
* atlassian or web-automation variants) are preserved.
*/
async function clearGeneratedRoot(rootDir) {
let entries;
try {
entries = await readdir(rootDir, { withFileTypes: true });
} catch {
return; // dir doesn't exist yet — nothing to clear
}
for (const entry of entries) {
if (entry.name === "node_modules") continue;
if (entry.name === MANIFEST_FILENAME) continue;
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
// Always recurse so node_modules at any depth is preserved.
await clearGeneratedRoot(fullPath);
// Remove the directory only if nothing protected remains inside it.
const remaining = await readdir(fullPath).catch(() => []);
if (remaining.length === 0) {
await rm(fullPath, { recursive: true, force: true });
}
} else {
await rm(fullPath, { force: true });
}
}
}
// ── Skill configurations ──────────────────────────────────────────────────
const SCRIPTS_SKILL_CONFIGS = {
atlassian: {
canonicalScripts: "skills/atlassian/shared/scripts",
// Files to copy from canonicalScripts into each agent's scripts/ dir
scriptFiles: ["src", "tsconfig.json", "pnpm-lock.yaml"],
},
"web-automation": {
canonicalScripts: "skills/web-automation/shared",
scriptFiles: [
"auth.ts",
"browse.ts",
"check-install.js",
"extract.js",
"flow.ts",
"lib",
"scan-local-app.ts",
"scrape.ts",
"test-full.ts",
"test-minimal.ts",
"test-profile.ts",
"tsconfig.json",
"turndown-plugin-gfm.d.ts",
"pnpm-lock.yaml",
],
},
};
// ── Main generator ────────────────────────────────────────────────────────
/**
* Regenerate all agent variants from canonical sources.
*
* @param {string} repoRoot - Absolute path to repo root (canonical sources are read from here).
* @param {object} [options]
* @param {boolean} [options.dryRun] - If true, don't write files (future use).
* @param {string} [options.targetRoot] - Write generated output here instead of `repoRoot`.
* Canonical sources are always read from `repoRoot`. Use this to generate into a
* temp directory for drift detection without modifying on-disk files.
* @returns {Promise<{generatedRoots: string[]}>}
*/
export async function generateSkills(repoRoot = REPO_ROOT, options = {}) {
const { dryRun = false, targetRoot } = options;
const writeRoot = targetRoot ?? repoRoot;
if (dryRun) {
return { generatedRoots: GENERATED_ROOTS };
}
const skillOnlySkills = ["create-plan", "do-task", "implement-plan"];
// 1. Generate skill-only skills (create-plan, do-task, implement-plan)
for (const skillName of skillOnlySkills) {
for (const agentName of AGENTS) {
const rootRel = `skills/${skillName}/${agentName}`;
await generateSkillOnlyVariant(repoRoot, writeRoot, skillName, agentName, rootRel);
}
// pi-package mirror (same source as pi variant)
const piPackageRel = `pi-package/skills/${skillName}`;
await generateSkillOnlyVariant(repoRoot, writeRoot, skillName, "pi", piPackageRel);
}
// 2. Generate scripts skills (atlassian, web-automation)
for (const [skillName, config] of Object.entries(SCRIPTS_SKILL_CONFIGS)) {
for (const agentName of AGENTS) {
const rootRel = `skills/${skillName}/${agentName}`;
await generateScriptsSkillVariant(repoRoot, writeRoot, skillName, agentName, rootRel, config);
}
// pi-package mirror: same source as pi variant but a distinct package name
// ("pi-mirror" suffix) so the workspace has no duplicate package names.
const piPackageRel = `pi-package/skills/${skillName}`;
await generateScriptsSkillVariant(repoRoot, writeRoot, skillName, "pi", piPackageRel, config, "pi-mirror");
}
// 3. Generate reviewer-runtime pi variant
await generateReviewerRuntimePi(repoRoot, writeRoot);
return { generatedRoots: GENERATED_ROOTS };
}
// ── CLI entry point ────────────────────────────────────────────────────────
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const result = await generateSkills(REPO_ROOT);
console.log(
`Generated ${result.generatedRoots.length} roots from canonical sources.`,
);
process.exit(0);
} catch (err) {
console.error("generate-skills: fatal error:", err.message ?? err);
process.exit(1);
}
}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
usage() {
cat <<'EOF'
Usage:
./scripts/install-pi-package.sh --global
./scripts/install-pi-package.sh --local
Options:
--global Install the repo path into ~/.pi/agent/settings.json
--local Install the repo path into .pi/settings.json for the current project
EOF
}
require_command() {
local command_name=$1
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Missing required command: $command_name" >&2
exit 1
fi
}
pnpm_cmd=()
resolve_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
pnpm_cmd=(pnpm)
return
fi
if command -v corepack >/dev/null 2>&1; then
pnpm_cmd=(corepack pnpm)
return
fi
echo "Missing required command: pnpm (or corepack)" >&2
exit 1
}
run_pnpm() {
"${pnpm_cmd[@]}" "$@"
}
require_node_20() {
local node_major
node_major=$(node -p "process.versions.node.split('.')[0]")
if (( node_major < 20 )); then
echo "Node.js 20+ is required. Found Node.js $(node -v)." >&2
exit 1
fi
}
install_scope=
if [[ $# -ne 1 ]]; then
usage >&2
exit 1
fi
case "$1" in
--global)
install_scope="global"
;;
--local)
install_scope="local"
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 1
;;
esac
require_command pi
require_command node
require_node_20
resolve_pnpm
case "$install_scope" in
global)
pi install "$ROOT_DIR"
;;
local)
pi install -l "$ROOT_DIR"
;;
esac
echo "Bootstrapping Atlassian runtime dependencies..."
run_pnpm install --frozen-lockfile --dir "${ROOT_DIR}/pi-package/skills/atlassian/scripts"
echo "Bootstrapping web-automation runtime dependencies..."
run_pnpm install --frozen-lockfile --dir "${ROOT_DIR}/pi-package/skills/web-automation/scripts"
run_pnpm --dir "${ROOT_DIR}/pi-package/skills/web-automation/scripts" exec cloakbrowser install
echo "Rebuilding native web-automation dependencies..."
run_pnpm rebuild --dir "${ROOT_DIR}/pi-package/skills/web-automation/scripts" better-sqlite3 esbuild
echo "Installed Pi packages now visible to this scope:"
pi list
echo "Pi package installed (${install_scope}) and runtime dependencies bootstrapped."
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* assert-no-pnpm-version-pin.mjs CI regression guard (followup: fix pnpm version conflict)
*
* Ensures no .github/workflows/*.yml file pins pnpm via a `version:` key
* under a `pnpm/action-setup` step. The canonical version source is
* `package.json#packageManager`, which carries an exact version + integrity
* hash. Duplicating the version in the workflow creates a conflict that
* pnpm/action-setup@v4 treats as an error.
*
* Usage:
* node scripts/lib/assert-no-pnpm-version-pin.mjs
* pnpm run verify:ci
*
* Exit codes:
* 0 no version pin found
* 1 one or more violations found (details on stderr)
*/
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "../..");
const WORKFLOWS_DIR = path.join(REPO_ROOT, ".github", "workflows");
let violations = 0;
// Read workflow files — silently pass if directory doesn't exist
let files;
try {
files = readdirSync(WORKFLOWS_DIR).filter(
(f) => f.endsWith(".yml") || f.endsWith(".yaml")
);
} catch {
process.stdout.write("OK: no .github/workflows directory found; nothing to check.\n");
process.exit(0);
}
for (const file of files) {
const fullPath = path.join(WORKFLOWS_DIR, file);
const content = readFileSync(fullPath, "utf8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
// Locate a step that uses pnpm/action-setup
if (!lines[i].includes("pnpm/action-setup")) continue;
// Look ahead up to 10 lines for a `version:` key in the same step
const end = Math.min(i + 10, lines.length);
for (let j = i + 1; j < end; j++) {
const ahead = lines[j];
// A new step begins at a `- name:` or `- uses:` list item → stop
if (/^\s*-\s+(name|uses)\s*:/.test(ahead)) break;
// `version:` key found inside this step → violation
if (/^\s+version\s*:/.test(ahead)) {
process.stderr.write(
`ERROR: ${file}:${j + 1}: 'version:' key found under pnpm/action-setup step.\n` +
` Remove 'with.version'; let package.json#packageManager be the single\n` +
` source of truth for the pnpm version (exact version + integrity hash).\n\n`
);
violations++;
break;
}
}
}
}
if (violations > 0) {
process.stderr.write(`${violations} violation(s) found.\n`);
process.exit(1);
}
process.stdout.write("OK: no pnpm version pins found in workflow files.\n");
process.exit(0);
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# portable.sh — POSIX-safe helper functions for BSD/GNU shell portability
#
# Source this file in scripts that need cross-platform variants of:
# - stat(1) — BSD uses -f, GNU uses -c
#
# Usage:
# source "$(dirname "${BASH_SOURCE[0]}")/portable.sh"
# portable_stat_perms "$file" # -> octal permissions string, e.g. "755"
#
# Supported platforms:
# - macOS (BSD stat)
# - Linux/Ubuntu (GNU stat)
# portable_stat_perms <path>
# Outputs the file's permission bits as an octal string (e.g. "755").
# Exits non-zero if stat fails.
portable_stat_perms() {
local path="$1"
case "$(uname -s)" in
Darwin)
stat -f '%Lp' "$path"
;;
*)
stat -c '%a' "$path"
;;
esac
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* run-check.mjs aggregate quality check runner (M1, S-106)
*
* Runs every quality gate in sequence and reports a summary.
* All steps run even if earlier steps fail, so you get a complete
* picture of the repository health in one pass.
*
* Transitional contract (M1):
* This script may exit non-zero. Pre-existing failures are recorded in
* docs/CLEANUP-BASELINE.md. Only issues introduced by new changes (not
* listed in the baseline) constitute a regression.
*
* Usage:
* node scripts/lib/run-check.mjs # full check
* pnpm run check # same, via pnpm
*/
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "../..");
// ── Steps ──────────────────────────────────────────────────────────────────
const STEPS = [
{ label: "lint", cmd: "pnpm", args: ["run", "lint"] },
{ label: "typecheck", cmd: "pnpm", args: ["run", "typecheck"] },
{ label: "test", cmd: "pnpm", args: ["run", "test"] },
{ label: "verify:pi", cmd: "pnpm", args: ["run", "verify:pi"] },
{ label: "verify:reviewers", cmd: "pnpm", args: ["run", "verify:reviewers"] },
{ label: "verify:docs", cmd: "pnpm", args: ["run", "verify:docs"] },
{ label: "verify:generated", cmd: "pnpm", args: ["run", "verify:generated"] },
{ label: "verify:ci", cmd: "pnpm", args: ["run", "verify:ci"] },
];
// ── Runner ─────────────────────────────────────────────────────────────────
const RESET = "\x1b[0m";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
function colorize(color, text) {
// Respect NO_COLOR env variable
if (process.env.NO_COLOR || process.env.CI) return text;
return `${color}${text}${RESET}`;
}
const results = [];
for (const step of STEPS) {
process.stdout.write(`\n${colorize(BOLD, `=== ${step.label} ===`)}\n`);
const result = spawnSync(step.cmd, step.args, {
cwd: REPO_ROOT,
stdio: "inherit",
encoding: "utf8",
shell: false,
});
const ok = result.status === 0 && !result.error;
results.push({ label: step.label, ok, status: result.status ?? -1 });
}
// ── Summary ────────────────────────────────────────────────────────────────
process.stdout.write(`\n${colorize(BOLD, "=== check summary ===")}\n`);
const failures = [];
for (const r of results) {
if (r.ok) {
process.stdout.write(
` ${colorize(GREEN, "PASS")} ${r.label}\n`
);
} else {
process.stdout.write(
` ${colorize(RED, "FAIL")} ${r.label} ${colorize(DIM, `(exit ${r.status})`)} — see docs/CLEANUP-BASELINE.md if pre-existing\n`
);
failures.push(r.label);
}
}
process.stdout.write("\n");
if (failures.length === 0) {
process.stdout.write(colorize(GREEN, "All checks passed.\n"));
process.exit(0);
} else {
process.stdout.write(
colorize(
RED,
`${failures.length} check(s) failed: ${failures.join(", ")}\n`
)
);
process.exit(1);
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* run-link-check.mjs markdown link-check runner (M1, S-104)
*
* Runs markdown-link-check across README.md, docs/, and every SKILL.md
* (excluding node_modules and generated agent-variant directories).
*
* Modes:
* --offline (default) checks only repo-relative links and #anchor links.
* All http/https links are ignored. Safe for CI and local dev
* without network access.
* --online checks all links, including external URLs, with timeouts
* and retries as configured in markdown-link-check.online.json.
*
* Exit codes:
* 0 all checked links are alive (or ignored in offline mode)
* 1 one or more broken links found
*/
import { spawnSync } from "node:child_process";
import { readdirSync, existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "../..");
// ── CLI arguments ──────────────────────────────────────────────────────────
const online = process.argv.includes("--online");
const configFile = online
? path.join(REPO_ROOT, "markdown-link-check.online.json")
: path.join(REPO_ROOT, "markdown-link-check.json");
// ── File discovery ─────────────────────────────────────────────────────────
const SKIP_PATHS = new Set([
"skills/atlassian/codex",
"skills/atlassian/claude-code",
"skills/atlassian/cursor",
"skills/atlassian/opencode",
"skills/atlassian/pi",
"skills/web-automation/claude-code",
"skills/web-automation/cursor",
"skills/web-automation/opencode",
"skills/web-automation/pi",
"pi-package",
]);
// Also skip any _source/ subdirectory within skills — canonical source files
// use relative paths calibrated to the generated location (one level shallower).
const SKIP_SEGMENT = "_source";
function shouldSkip(absPath) {
const rel = path.relative(REPO_ROOT, absPath);
for (const skip of SKIP_PATHS) {
if (rel === skip || rel.startsWith(skip + path.sep)) return true;
}
const parts = rel.split(path.sep);
if (parts.includes("node_modules")) return true;
// Skip canonical _source/ directories (links calibrated to generated location)
if (parts.includes(SKIP_SEGMENT)) return true;
return false;
}
function collectMarkdownFiles(dir) {
const found = [];
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return found;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (shouldSkip(full)) continue;
if (entry.isDirectory()) {
found.push(...collectMarkdownFiles(full));
} else if (
entry.isFile() &&
(entry.name.endsWith(".md") || entry.name === "SKILL.md")
) {
found.push(full);
}
}
return found;
}
// ── Collect target files ───────────────────────────────────────────────────
const files = [
path.join(REPO_ROOT, "README.md"),
...collectMarkdownFiles(path.join(REPO_ROOT, "docs")),
...collectMarkdownFiles(path.join(REPO_ROOT, "skills")),
].filter(existsSync);
// De-duplicate (README.md could appear twice)
const uniqueFiles = [...new Set(files)];
if (uniqueFiles.length === 0) {
console.log("link-check: no markdown files found — nothing to check.");
process.exit(0);
}
console.log(
`link-check: checking ${uniqueFiles.length} file(s) ` +
`[mode: ${online ? "online" : "offline"}]…`
);
// ── Run markdown-link-check ────────────────────────────────────────────────
const mlcBin = path.join(
REPO_ROOT,
"node_modules",
".bin",
"markdown-link-check"
);
let failures = 0;
for (const file of uniqueFiles.sort()) {
const result = spawnSync(
mlcBin,
["--config", configFile, "--quiet", file],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
);
const output = (result.stdout + result.stderr).trim();
const rel = path.relative(REPO_ROOT, file);
if (result.status !== 0) {
failures += 1;
console.error(`\n--- ${rel} ---`);
if (output) console.error(output);
}
}
if (failures > 0) {
console.error(
`\nlink-check: ${failures} file(s) have broken links. ` +
`See docs/CLEANUP-BASELINE.md for the as-is baseline.`
);
process.exit(1);
} else {
console.log("link-check: all links OK.");
process.exit(0);
}
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* run-shellcheck.mjs shell script quality wrapper (M1, S-105)
*
* Discovers *.sh files under scripts/ and skills/ (excluding node_modules
* and generated agent-variant directories), then runs shellcheck on each.
*
* shellcheck is a REQUIRED prerequisite. The script fails immediately when
* shellcheck is not found on PATH. Install it with:
* macOS: brew install shellcheck
* Debian: apt-get install shellcheck
*
* Exit codes:
* 0 all files passed shellcheck
* 1 one or more files have shellcheck findings
* 2 shellcheck is missing from PATH
*/
import { spawnSync } from "node:child_process";
import { readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "../..");
// ── Prerequisites check ────────────────────────────────────────────────────
function checkShellcheck() {
const result = spawnSync("shellcheck", ["--version"], { encoding: "utf8" });
if (result.error || result.status === null || result.status > 1) {
console.error(
[
"ERROR: shellcheck is not available on PATH.",
"",
"shellcheck is a required prerequisite for this repository.",
"Install it before running lint:",
"",
" macOS: brew install shellcheck",
" Debian/Ubuntu: sudo apt-get install shellcheck",
" Other: https://github.com/koalaman/shellcheck#installing",
].join("\n")
);
process.exit(2);
}
}
// ── File discovery ─────────────────────────────────────────────────────────
/**
* Directories to scan for *.sh files.
* Relative paths from REPO_ROOT.
*/
const SCAN_DIRS = ["scripts", "skills"];
/**
* Path segments that indicate a directory should be skipped entirely.
* Matches generated agent-variant bundles and npm/pnpm install artifacts.
*/
const SKIP_SEGMENTS = new Set([
"node_modules",
// Generated agent-variant directories (excluded per M1 workspace policy)
// skills/<skill>/codex — except web-automation/codex which is canonical
// We skip all codex variants to be safe; shellcheck only cares about .sh files
// and all variants share the same scripts anyway.
]);
/**
* Exact relative paths (from REPO_ROOT) to skip, matched after normalisation.
* These are the generated variants that duplicate canonical source.
*/
const SKIP_PATHS = new Set([
"skills/atlassian/codex",
"skills/atlassian/claude-code",
"skills/atlassian/cursor",
"skills/atlassian/opencode",
"skills/atlassian/pi",
"skills/web-automation/claude-code",
"skills/web-automation/cursor",
"skills/web-automation/opencode",
"skills/web-automation/pi",
"pi-package",
]);
function shouldSkip(absPath) {
const rel = path.relative(REPO_ROOT, absPath);
// Check exact-prefix matches (directory and its children)
for (const skip of SKIP_PATHS) {
if (rel === skip || rel.startsWith(skip + path.sep)) return true;
}
// Check path-segment matches (e.g. node_modules anywhere in path)
for (const seg of rel.split(path.sep)) {
if (SKIP_SEGMENTS.has(seg)) return true;
}
return false;
}
function collectShellFiles(dir) {
const found = [];
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return found; // directory does not exist — skip silently
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (shouldSkip(full)) continue;
if (entry.isDirectory()) {
found.push(...collectShellFiles(full));
} else if (entry.isFile() && entry.name.endsWith(".sh")) {
found.push(full);
}
}
return found;
}
// ── Main ───────────────────────────────────────────────────────────────────
checkShellcheck();
const files = SCAN_DIRS.flatMap((d) =>
collectShellFiles(path.join(REPO_ROOT, d))
);
if (files.length === 0) {
console.log("shellcheck: no .sh files found — nothing to check.");
process.exit(0);
}
console.log(`shellcheck: scanning ${files.length} file(s)…`);
let failures = 0;
for (const file of files.sort()) {
const result = spawnSync("shellcheck", ["-x", "--source-path=SCRIPTDIR", file], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const output = (result.stdout + result.stderr).trim();
const rel = path.relative(REPO_ROOT, file);
if (result.status !== 0) {
failures += 1;
// Print findings prefixed with the relative path so output is reproducible
// regardless of cwd.
console.error(`\n--- ${rel} ---`);
if (output) console.error(output);
} else {
// Quiet on success — only show problems
}
}
if (failures > 0) {
console.error(
`\nshellcheck: ${failures} file(s) have findings. ` +
`See docs/CLEANUP-BASELINE.md for the as-is baseline.`
);
process.exit(1);
} else {
console.log("shellcheck: all files passed.");
process.exit(0);
}
+77
View File
@@ -0,0 +1,77 @@
/**
* safe-replace-dir.mjs safely replace a directory within a safety-root boundary
*
* Exports:
* safeReplaceDir(source, target, safetyRoot) Promise<void>
*
* Usage:
* import { safeReplaceDir } from "./lib/safe-replace-dir.mjs";
* await safeReplaceDir("/path/to/source", "/safe/root/target", "/safe/root");
*
* Safety contract:
* - `target` must be a strict descendant of `safetyRoot` (not equal to it).
* - `target` must be a non-empty path.
* - Throws with a descriptive message if either constraint is violated.
*
* Behaviour:
* - Removes any existing content at `target` (rm -rf equivalent).
* - Creates `target` (and any missing parent directories).
* - Copies all files from `source` into `target`.
*/
import { cp, mkdir, realpath, rm } from "node:fs/promises";
import path from "node:path";
/**
* Safely replace `target` with the contents of `source`, enforcing that
* `target` is a strict descendant of `safetyRoot`.
*
* @param {string} source - Directory to copy from.
* @param {string} target - Directory to replace (will be removed then recreated).
* @param {string} safetyRoot - Ancestor boundary; `target` must be inside this.
* @returns {Promise<void>}
*/
export async function safeReplaceDir(source, target, safetyRoot) {
if (!target || target === "") {
throw new Error(`Refusing to replace unsafe target: (empty string)`);
}
const resolvedSafety = path.resolve(safetyRoot);
const resolvedTarget = path.resolve(target);
// Lexical check: target must be a strict descendant of safetyRoot.
const relative = path.relative(resolvedSafety, resolvedTarget);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || relative === "") {
throw new Error(`Refusing to replace target outside safety root: ${target}`);
}
// Real-path check: resolve the deepest existing ancestor of target's parent
// and verify it lies inside the real (symlink-resolved) safety root.
// This blocks a symlinked parent directory from redirecting outside the boundary.
const realSafety = await realpath(resolvedSafety);
let checkPath = path.dirname(resolvedTarget);
for (;;) {
try {
const realAncestor = await realpath(checkPath);
const realRel = path.relative(realSafety, realAncestor);
if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
throw new Error(`Refusing to replace target outside safety root: ${target}`);
}
break; // validation passed
} catch (err) {
if (err.code === "ENOENT") {
const parent = path.dirname(checkPath);
if (parent === checkPath) {
throw new Error(`Refusing to replace target outside safety root: ${target}`, { cause: err });
}
checkPath = parent;
continue;
}
throw err;
}
}
await rm(resolvedTarget, { recursive: true, force: true });
await mkdir(resolvedTarget, { recursive: true });
await cp(source, resolvedTarget, { recursive: true, force: true });
}
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# safe-replace-dir.sh — safely replace a directory within a safety-root boundary
#
# Provides safe_replace_dir() for sourcing, or run standalone:
# ./scripts/lib/safe-replace-dir.sh <source> <target> <safety_root>
#
# Safety contract (mirrors safe-replace-dir.mjs):
# - <target> must be a non-empty path.
# - <target> must be a strict descendant of <safety_root> (not equal to it).
# - Prints an error and returns/exits 1 if either constraint is violated.
#
# Usage (sourced):
# source "$(dirname "${BASH_SOURCE[0]}")/safe-replace-dir.sh"
# safe_replace_dir "$source" "$target" "$safety_root"
#
# Usage (standalone):
# ./scripts/lib/safe-replace-dir.sh /path/to/source /safe/root/target /safe/root
safe_replace_dir() {
local source=$1
local target=$2
local safety_root=$3
if [[ -z "$target" ]]; then
echo "safe_replace_dir: refusing to replace unsafe target: (empty string)" >&2
return 1
fi
# Resolve the real (symlink-resolved) safety root.
local abs_safety
abs_safety=$(cd "$safety_root" 2>/dev/null && pwd -P) || {
echo "safe_replace_dir: safety root does not exist: $safety_root" >&2
return 1
}
# Build an absolute lexical path for target's parent directory.
local target_parent target_base
target_base=$(basename "$target")
target_parent=$(dirname "$target")
# Make target_parent absolute without relying on cd (target may not exist yet).
if [[ "$target_parent" != /* ]]; then
target_parent="${PWD}/${target_parent}"
fi
# Walk up from target_parent to find the deepest existing directory,
# accumulating the non-existing path suffix as we go.
local suffix=""
local walk="$target_parent"
while [[ ! -d "$walk" ]]; do
local component
component=$(basename "$walk")
if [[ -z "$suffix" ]]; then
suffix="$component"
else
suffix="${component}/${suffix}"
fi
local next
next=$(dirname "$walk")
if [[ "$next" == "$walk" ]]; then
echo "safe_replace_dir: could not find existing ancestor for: $target" >&2
return 1
fi
walk="$next"
done
# Resolve the real path of the existing ancestor (follows symlinks).
local abs_parent
abs_parent=$(cd "$walk" && pwd -P) || {
echo "safe_replace_dir: could not resolve parent directory: $walk" >&2
return 1
}
# Reconstruct the full absolute target path.
local abs_target
if [[ -n "$suffix" ]]; then
abs_target="${abs_parent}/${suffix}/${target_base}"
else
abs_target="${abs_parent}/${target_base}"
fi
# Check that abs_target is strictly inside abs_safety
case "$abs_target" in
"${abs_safety}/"*) ;;
*)
echo "safe_replace_dir: refusing to replace target outside safety root: $target" >&2
return 1
;;
esac
rm -rf "$abs_target"
mkdir -p "$abs_target"
cp -R "${source}/." "$abs_target/"
}
# Allow standalone use
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
if [[ $# -ne 3 ]]; then
echo "Usage: $0 <source> <target> <safety_root>" >&2
exit 1
fi
safe_replace_dir "$1" "$2" "$3" || exit 1
fi
+663
View File
@@ -0,0 +1,663 @@
import { access, cp, lstat, mkdir, readFile, realpath, rm, stat, symlink, chmod, unlink, readdir } from "node:fs/promises";
import { constants, existsSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
export const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
export const CLIENTS = {
codex: {
id: "codex",
label: "Codex",
detectCommands: [["codex", "--version"]],
scopes: { global: { skillsRoot: "~/.codex/skills" } },
variant: "codex",
superpowers: {
roots: ["~/.agents/skills/superpowers", "~/.codex/superpowers/skills"],
layout: "flat",
},
reviewerRuntime: {
root: "~/.codex/skills/reviewer-runtime",
source: "skills/reviewer-runtime",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
"claude-code": {
id: "claude-code",
label: "Claude Code",
detectCommands: [["claude", "--version"]],
scopes: { global: { skillsRoot: "~/.claude/skills" } },
variant: "claude-code",
superpowers: {
roots: ["~/.claude/skills/superpowers"],
layout: "flat",
},
reviewerRuntime: {
root: "~/.claude/skills/reviewer-runtime",
source: "skills/reviewer-runtime",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
cursor: {
id: "cursor",
label: "Cursor",
detectCommands: [["cursor-agent", "--version"], ["cursor", "agent", "--version"]],
scopes: {
local: { skillsRoot: ".cursor/skills" },
global: { skillsRoot: "~/.cursor/skills" },
},
variant: "cursor",
superpowers: {
roots: [".cursor/skills/superpowers/skills", "~/.cursor/skills/superpowers/skills"],
layout: "cursor-nested",
targetSuffix: "superpowers/skills",
},
reviewerRuntime: {
root: "<skillsRoot>/reviewer-runtime",
source: "skills/reviewer-runtime",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
opencode: {
id: "opencode",
label: "OpenCode",
detectCommands: [["opencode", "--version"]],
scopes: { global: { skillsRoot: "~/.config/opencode/skills" } },
variant: "opencode",
superpowers: {
roots: ["~/.agents/skills/superpowers", "~/.config/opencode/skills/superpowers"],
layout: "flat",
},
reviewerRuntime: {
root: "~/.config/opencode/skills/reviewer-runtime",
source: "skills/reviewer-runtime",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
pi: {
id: "pi",
label: "Pi",
detectCommands: [["pi", "--version"]],
scopes: {
packageGlobal: { skillsRoot: "~/.pi/agent/skills", packageMode: true, piInstallArg: "" },
packageLocal: { skillsRoot: ".pi/skills", packageMode: true, piInstallArg: "-l" },
global: { skillsRoot: "~/.pi/agent/skills" },
local: { skillsRoot: ".pi/skills" },
},
variant: "pi",
superpowers: {
roots: ["~/.agents/skills/superpowers", "~/.pi/agent/skills/superpowers", ".pi/skills/superpowers"],
layout: "flat",
},
reviewerRuntime: {
root: "<skillsRoot>/reviewer-runtime/pi",
source: "skills/reviewer-runtime/pi",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
};
export const SKILLS = {
atlassian: {
name: "atlassian",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: [],
requiresReviewerRuntime: false,
bootstrap: "pnpm-install",
bootstrapPrerequisites: ["node>=20", "pnpm|corepack"],
auxiliary: { shared: "build-time/shared-not-installed" },
},
"create-plan": {
name: "create-plan",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: ["brainstorming", "writing-plans"],
requiresReviewerRuntime: true,
},
"do-task": {
name: "do-task",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: ["brainstorming", "test-driven-development", "verification-before-completion", "finishing-a-development-branch"],
conditionalSuperpowers: ["using-git-worktrees"],
requiresReviewerRuntime: true,
requiresNotifier: true,
},
"implement-plan": {
name: "implement-plan",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: ["executing-plans", "using-git-worktrees", "verification-before-completion", "finishing-a-development-branch"],
requiresReviewerRuntime: true,
},
"web-automation": {
name: "web-automation",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: [],
requiresReviewerRuntime: false,
bootstrap: "web-automation",
bootstrapPrerequisites: ["node>=20", "pnpm|corepack", "cloakbrowser"],
},
};
export function expandHome(value, cwd = process.cwd(), homeDir = homedir()) {
if (!value) return value;
let expanded = value.replace(/^~(?=$|\/)/, homeDir);
if (!path.isAbsolute(expanded)) expanded = path.resolve(cwd, expanded);
return expanded;
}
export function resolveClientScope(clientId, scope = "global", cwd = process.cwd()) {
const client = CLIENTS[clientId];
if (!client) throw new Error(`Unsupported client: ${clientId}`);
const scopeConfig = client.scopes[scope];
if (!scopeConfig) throw new Error(`Unsupported scope for ${clientId}: ${scope}`);
const skillsRoot = expandHome(scopeConfig.skillsRoot, cwd);
return { ...scopeConfig, skillsRoot };
}
export function reviewerRuntimeRoot(clientId, skillsRoot, cwd = process.cwd()) {
const template = CLIENTS[clientId].reviewerRuntime.root;
return expandHome(template.replace("<skillsRoot>", skillsRoot), cwd);
}
export function parseReviewerShorthand(value) {
if (typeof value !== "string") return null;
const slash = value.indexOf("/");
if (slash <= 0) return null;
if (value.slice(0, slash) !== "pi") return null;
const model = value.slice(slash + 1);
if (!model) return null;
return { reviewerCli: "pi", reviewerModel: model };
}
export function getSkillSource(skillName, clientId, repoRoot = process.cwd()) {
const skill = SKILLS[skillName];
const client = CLIENTS[clientId];
if (!skill || !client || !skill.variants.includes(client.variant)) return null;
if (clientId === "pi") return path.join(repoRoot, "pi-package", "skills", skillName);
return path.join(repoRoot, "skills", skillName, client.variant);
}
export async function pathExists(candidate) {
try {
await access(candidate, constants.F_OK);
return true;
} catch {
return false;
}
}
export async function detectInstalledClients({ cwd = process.cwd() } = {}) {
const result = {};
for (const [clientId, client] of Object.entries(CLIENTS)) {
let confidence = "not-found";
let detail = "not detected";
for (const command of client.detectCommands) {
const proc = spawnSync(command[0], command.slice(1), { encoding: "utf8" });
if (proc.status === 0) {
confidence = "cli";
detail = (proc.stdout || proc.stderr || "detected").trim().split("\n")[0];
break;
}
}
if (confidence === "not-found") {
for (const scopeName of Object.keys(client.scopes)) {
const { skillsRoot } = resolveClientScope(clientId, scopeName, cwd);
if (await pathExists(skillsRoot)) {
confidence = "directory";
detail = skillsRoot;
break;
}
}
}
result[clientId] = { clientId, label: client.label, confidence, detail };
}
return result;
}
export async function detectInstalledSkills({ skillsRoot, clientId, repoRoot = process.cwd() }) {
const state = {};
for (const skillName of Object.keys(SKILLS)) {
if (getSkillSource(skillName, clientId) === null) {
state[skillName] = { state: "unsupported", path: null };
continue;
}
const target = path.join(skillsRoot, skillName);
const skillMd = path.join(target, "SKILL.md");
let installState = "not-installed";
if (existsSync(skillMd)) {
installState = "installed";
const source = getSkillSource(skillName, clientId, repoRoot);
const sourceSkill = source && path.join(source, "SKILL.md");
if (sourceSkill && existsSync(sourceSkill)) {
const [targetStat, sourceStat] = await Promise.all([stat(skillMd), stat(sourceSkill)]);
if (sourceStat.mtimeMs > targetStat.mtimeMs + 1000) installState = "stale";
} else {
installState = "unknown";
}
}
state[skillName] = {
state: installState,
path: target,
};
}
return state;
}
export async function findInstalledSuperpowers(clientId, cwd = process.cwd(), { homeDir = homedir() } = {}) {
const client = CLIENTS[clientId];
const found = new Set();
for (const root of client.superpowers.roots) {
const expanded = expandHome(root, cwd, homeDir);
if (await pathExists(expanded)) found.add(expanded);
}
if (clientId === "claude-code") {
for (const root of await findClaudeCodeSuperpowersPluginRoots(homeDir)) found.add(root);
}
if (clientId === "cursor") {
for (const root of await findCursorSuperpowersPluginRoots(homeDir)) found.add(root);
}
return [...found];
}
async function findClaudeCodeSuperpowersPluginRoots(homeDir) {
const pluginId = "superpowers@claude-plugins-official";
const settings = await readJsonIfExists(path.join(homeDir, ".claude", "settings.json"));
if (settings?.enabledPlugins?.[pluginId] !== true) return [];
const installed = await readJsonIfExists(path.join(homeDir, ".claude", "plugins", "installed_plugins.json"));
const entries = installed?.plugins?.[pluginId];
if (!Array.isArray(entries)) return [];
const roots = [];
for (const entry of entries) {
if (!entry?.installPath) continue;
const skillsRoot = path.join(entry.installPath, "skills");
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
}
return roots;
}
async function findCursorSuperpowersPluginRoots(homeDir) {
const pluginRoot = path.join(homeDir, ".cursor", "plugins", "cache", "cursor-public", "superpowers");
let entries;
try {
entries = await readdir(pluginRoot, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
const roots = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillsRoot = path.join(pluginRoot, entry.name, "skills");
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
}
return roots.sort();
}
function piPackageSettingsPath(scope, repoRoot) {
if (scope === "packageLocal") return path.join(repoRoot, ".pi", "settings.json");
return path.join(homedir(), ".pi", "agent", "settings.json");
}
async function readJsonIfExists(filePath) {
try {
return JSON.parse(await readFile(filePath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
export async function isPiPackageInstalled({ scope, repoRoot }) {
const settingsPath = piPackageSettingsPath(scope, repoRoot);
const settings = await readJsonIfExists(settingsPath);
if (!Array.isArray(settings?.packages)) return false;
const settingsDir = path.dirname(settingsPath);
const expected = path.resolve(repoRoot);
return settings.packages.some((packagePath) => path.resolve(settingsDir, packagePath) === expected);
}
export function isBootstrapInstalled(action, target) {
const scriptsDir = path.join(target, "scripts");
if (action === "pnpm-install") return existsSync(path.join(scriptsDir, "node_modules"));
if (action === "web-automation") {
return existsSync(path.join(scriptsDir, "node_modules"))
&& existsSync(path.join(scriptsDir, "node_modules", ".bin", "cloakbrowser"));
}
return false;
}
export async function detectHelperInstallState({ source, target, files }) {
let differs = false;
for (const file of files) {
const sourceFile = path.join(source, file);
const targetFile = path.join(target, file);
if (!existsSync(targetFile)) return "not-installed";
if (!existsSync(sourceFile)) return "unknown";
const [sourceContents, targetContents] = await Promise.all([readFile(sourceFile), readFile(targetFile)]);
if (!sourceContents.equals(targetContents)) differs = true;
}
return differs ? "stale" : "installed";
}
export function piPackageCommand({ action, repoRoot, piInstallArg = "" }) {
if (["install", "update", "reinstall"].includes(action)) {
const args = ["install"];
if (piInstallArg) args.push(piInstallArg);
args.push(repoRoot);
return ["pi", args];
}
if (action === "remove") {
const args = ["remove"];
if (piInstallArg) args.push(piInstallArg);
args.push(repoRoot);
return ["pi", args];
}
throw new Error(`pi package mode does not support action: ${action}`);
}
export function requiredSuperpowersFor(actions) {
const required = new Set();
for (const [skillName, action] of Object.entries(actions || {})) {
if (!["install", "update", "reinstall"].includes(action)) continue;
for (const skill of SKILLS[skillName]?.requiresSuperpowers || []) required.add(skill);
}
return [...required];
}
async function buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action = null }) {
if (action === "skip") return null;
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperSource = path.join(repoRoot, CLIENTS[clientId].reviewerRuntime.source);
const helperFiles = CLIENTS[clientId].reviewerRuntime.files;
const helperState = await detectHelperInstallState({ source: helperSource, target: helperTarget, files: helperFiles });
const effectiveAction = action || (helperState === "stale" || helperState === "unknown" ? "update" : "install");
const operation = {
kind: "helper",
helper: "reviewer-runtime",
clientId,
scope,
action: effectiveAction,
source: helperSource,
target: helperTarget,
files: helperFiles,
skillsRoot,
};
if (!action && helperState === "installed") {
operation.status = "skipped";
operation.details = "runtime helper already installed";
}
return operation;
}
export async function buildOperationPlan({ selections, repoRoot = process.cwd(), assumeYes = false, superpowersByClient = null }) {
const operations = [];
const prompts = [];
const reportRows = [];
const plannedHelpers = new Set();
for (const selection of selections || []) {
const { clientId, scope = "global" } = selection;
const client = CLIENTS[clientId];
if (!client) throw new Error(`Unsupported client: ${clientId}`);
const scopeInfo = selection.skillsRoot ? { skillsRoot: selection.skillsRoot } : resolveClientScope(clientId, scope, repoRoot);
const skillsRoot = scopeInfo.skillsRoot;
if (clientId === "pi" && scopeInfo.packageMode) {
const action = selection.action || "install";
if (action === "skip") continue;
if (["update", "reinstall"].includes(action)) {
operations.push({ kind: "sync-pi-package", clientId, scope, action: "sync", repoRoot });
}
const packageInstalled = await isPiPackageInstalled({ scope, repoRoot });
if (action === "remove") {
operations.push({
kind: "pi-package",
clientId,
scope,
action,
repoRoot,
piInstallArg: scopeInfo.piInstallArg || "",
status: packageInstalled ? undefined : "skipped",
details: packageInstalled ? "" : "not installed in Pi settings",
});
continue;
}
operations.push({
kind: "pi-package",
clientId,
scope,
action,
repoRoot,
piInstallArg: scopeInfo.piInstallArg || "",
status: packageInstalled && action === "install" ? "skipped" : undefined,
details: packageInstalled && action === "install" ? "already installed in Pi settings" : "",
});
for (const skillName of Object.keys(SKILLS)) {
if (getSkillSource(skillName, clientId, repoRoot)) {
operations.push({ kind: "package-skill", clientId, scope, skill: skillName, action: "included", status: "included", details: "included in Pi package" });
}
}
for (const bootstrap of [
{ skill: "atlassian", action: "pnpm-install" },
{ skill: "web-automation", action: "web-automation" },
]) {
const target = path.join(repoRoot, "pi-package", "skills", bootstrap.skill);
const installed = isBootstrapInstalled(bootstrap.action, target);
const skipBootstrap = action === "install" && installed;
operations.push({
kind: "bootstrap",
clientId,
scope,
skill: bootstrap.skill,
action: bootstrap.action,
displayAction: "bootstrap-deps",
target,
status: skipBootstrap ? "skipped" : undefined,
details: skipBootstrap ? "runtime dependencies already installed" : target,
});
}
continue;
}
const installed = await detectInstalledSkills({ skillsRoot, clientId, repoRoot });
const actions = selection.actions || {};
const helperActions = selection.helperActions || {};
const missingSuperpowers = requiredSuperpowersFor(actions);
if (missingSuperpowers.length > 0) {
const installedSuperpowers = superpowersByClient && Object.hasOwn(superpowersByClient, clientId)
? superpowersByClient[clientId]
: await findInstalledSuperpowers(clientId, repoRoot);
if (installedSuperpowers.length === 0) {
prompts.push({ kind: "missing-superpowers", clientId, scope, required: missingSuperpowers });
}
}
for (const [helper, action] of Object.entries(helperActions)) {
if (helper !== "reviewer-runtime") continue;
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
if (plannedHelpers.has(helperKey)) continue;
plannedHelpers.add(helperKey);
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action });
if (helperOperation) operations.push(helperOperation);
}
for (const [skillName, action] of Object.entries(actions)) {
const source = getSkillSource(skillName, clientId, repoRoot);
if (!source) {
operations.push({ kind: "skill", clientId, scope, skill: skillName, action: "unsupported", status: "skipped", details: "variant unavailable" });
continue;
}
if (action === "skip") continue;
const target = path.join(skillsRoot, skillName);
if (clientId === "pi" && !operations.some((op) => op.kind === "sync-pi-package")) {
operations.push({ kind: "sync-pi-package", clientId, scope, action: "sync", repoRoot });
}
operations.push({ kind: "skill", clientId, scope, skill: skillName, action, source, target, skillsRoot });
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].requiresReviewerRuntime) {
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
if (!plannedHelpers.has(helperKey)) {
plannedHelpers.add(helperKey);
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot });
if (helperOperation) operations.push(helperOperation);
}
}
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].bootstrap) {
operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, item: `${skillName} deps`, action: SKILLS[skillName].bootstrap, displayAction: "bootstrap-deps", target });
}
}
const workflowSkills = Object.keys(SKILLS).filter((skillName) => (SKILLS[skillName].requiresSuperpowers || []).length > 0);
const removing = workflowSkills.filter((skillName) => actions[skillName] === "remove");
if (removing.length > 0) {
const remaining = workflowSkills.some((skillName) => actions[skillName] !== "remove" && installed[skillName]?.state === "installed");
if (!remaining) prompts.push({ kind: "remove-superpowers", clientId, scope, removedWorkflowSkills: removing });
}
}
for (const op of operations) {
reportRows.push({
client: op.clientId,
scope: op.scope,
item: op.item || op.skill || op.helper || op.kind,
action: op.displayAction || op.action,
status: op.status || "planned",
details: op.details || op.target || "",
});
}
return { operations, prompts, reportRows, assumeYes };
}
/**
* Remove the target of an operation (skill, helper, or superpowers).
*
* Validates that the target is within the skills root before removing.
* Handles both regular directories and symbolic links.
* Idempotent: succeeds even when the target does not exist.
*
* @param {object} op - Operation object with at least `target` and `skillsRoot`.
* @returns {Promise<object>} Operation with `status: "ok"`.
*/
export async function removeTarget(op) {
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
const info = existsSync(op.target) ? await lstat(op.target) : null;
if (info?.isSymbolicLink()) await unlink(op.target);
else await rm(op.target, { recursive: true, force: true });
return { ...op, status: "ok" };
}
export async function validateRemoveTarget(target, skillsRoot, { repoRoot = process.cwd() } = {}) {
const resolvedRoot = path.resolve(skillsRoot);
const resolvedTarget = path.resolve(target);
const home = path.resolve(homedir());
const resolvedRepo = path.resolve(repoRoot);
if (resolvedTarget === resolvedRoot) throw new Error("refusing to remove skills root itself");
if (resolvedTarget === home) throw new Error("refusing to remove home directory");
if (resolvedTarget === resolvedRepo) throw new Error("refusing to remove repository root");
if (resolvedTarget === path.parse(resolvedTarget).root) throw new Error("refusing to remove filesystem root");
const relative = path.relative(resolvedRoot, resolvedTarget);
if (relative.startsWith("..") || path.isAbsolute(relative) || relative === "") {
throw new Error(`refusing to remove target outside skills root: ${target}`);
}
try {
const info = await lstat(resolvedTarget);
if (!info.isSymbolicLink()) {
const [realRoot, realTarget] = await Promise.all([realpath(resolvedRoot), realpath(resolvedTarget)]);
const realRelative = path.relative(realRoot, realTarget);
if (realRelative.startsWith("..") || path.isAbsolute(realRelative) || realRelative === "") {
throw new Error(`refusing to remove real target outside skills root: ${target}`);
}
}
} catch (error) {
if (error && error.code !== "ENOENT") throw error;
}
return true;
}
export async function copyDirectoryReplacing(source, target) {
await mkdir(path.dirname(target), { recursive: true });
await rm(target, { recursive: true, force: true });
await cp(source, target, { recursive: true, force: true, dereference: false });
}
export async function installHelperAllowlist({ source, target, files }) {
await mkdir(target, { recursive: true });
for (const file of files) {
await cp(path.join(source, file), path.join(target, file), { force: true });
if (file.endsWith(".sh")) await chmod(path.join(target, file), 0o755);
}
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, { encoding: "utf8", stdio: "pipe", ...options });
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || `${command} exited ${result.status}`).trim();
throw new Error(detail);
}
return (result.stdout || result.stderr || "ok").trim();
}
function resolvePnpmCommand() {
if (spawnSync("pnpm", ["--version"], { encoding: "utf8" }).status === 0) return ["pnpm"];
if (spawnSync("corepack", ["--version"], { encoding: "utf8" }).status === 0) return ["corepack", "pnpm"];
throw new Error("missing prerequisite: pnpm or corepack");
}
function requireNode20() {
const major = Number.parseInt(process.versions.node.split(".")[0], 10);
if (major < 20) throw new Error(`missing prerequisite: Node.js 20+ (found ${process.version})`);
}
export async function executeOperation(op) {
if (op.action === "unsupported" || op.status === "skipped") return { ...op, status: "skipped" };
if (op.kind === "package-skill") return { ...op, status: "included" };
if (op.kind === "sync-pi-package") {
// Use the canonical generator (pnpm run sync:pi / node scripts/generate-skills.mjs).
runCommand(process.execPath, [path.join(op.repoRoot, "scripts", "generate-skills.mjs")], { cwd: op.repoRoot });
return { ...op, status: "ok" };
}
if (op.kind === "pi-package") {
const [command, args] = piPackageCommand(op);
runCommand(command, args, { cwd: op.repoRoot });
return { ...op, status: "ok" };
}
if (op.kind === "skill") {
if (op.action === "remove") return removeTarget(op);
await copyDirectoryReplacing(op.source, op.target);
return { ...op, status: "ok" };
}
if (op.kind === "helper") {
if (op.action === "remove") return removeTarget(op);
await installHelperAllowlist(op);
return { ...op, status: "ok" };
}
if (op.kind === "superpowers") {
if (op.action === "remove") return removeTarget(op);
await mkdir(path.dirname(op.target), { recursive: true });
if (op.mode === "copy") {
await copyDirectoryReplacing(op.source, op.target);
} else {
await rm(op.target, { recursive: true, force: true });
await symlink(op.source, op.target, "dir");
}
return { ...op, status: "ok" };
}
if (op.kind === "bootstrap") {
requireNode20();
const pnpm = resolvePnpmCommand();
const scriptsDir = path.join(op.target, "scripts");
if (op.action === "pnpm-install") {
runCommand(pnpm[0], [...pnpm.slice(1), "install", "--frozen-lockfile", "--dir", scriptsDir]);
} else if (op.action === "web-automation") {
runCommand(pnpm[0], [...pnpm.slice(1), "install", "--frozen-lockfile", "--dir", scriptsDir]);
runCommand(pnpm[0], [...pnpm.slice(1), "--dir", scriptsDir, "exec", "cloakbrowser", "install"]);
runCommand(pnpm[0], [...pnpm.slice(1), "rebuild", "--dir", scriptsDir, "better-sqlite3", "esbuild"]);
}
return { ...op, status: "ok" };
}
return { ...op, status: "warning", details: "operation is planned/manual" };
}
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { stdin as input, stdout as output } from "node:process";
import readline from "node:readline/promises";
import path from "node:path";
import {
CLIENTS,
SKILLS,
buildOperationPlan,
detectHelperInstallState,
detectInstalledClients,
detectInstalledSkills,
executeOperation,
isPiPackageInstalled,
parseReviewerShorthand,
resolveClientScope,
reviewerRuntimeRoot,
} from "./lib/skill-manager-core.mjs";
function usage() {
return `Usage:
node scripts/manage-skills.mjs [--dry-run]
node scripts/manage-skills.mjs --plan-only --answers <answers.json>
node scripts/manage-skills.mjs --client <client> --scope <scope> --skill <skill> --action <install|update|reinstall|remove|skip> [--yes]
Options:
--dry-run Prompt or detect normally, but do not modify files.
--plan-only Non-interactive mode. Requires --answers and emits JSON.
--answers <file> JSON answers file with { "selections": [...] }.
--client <client> codex, claude-code, cursor, opencode, or pi.
--scope <scope> Client scope. Examples: global, local, packageGlobal, packageLocal.
--skill <skill> Skill to manage. May be repeated.
--action <action> Action for --skill entries. Defaults to install.
--yes Execute planned operations without final confirmation.
--pi-package With --client pi, default to packageGlobal scope (full bundle).
--reviewer <value> Parse/display reviewer shorthand, e.g. pi/claude-opus-4-7.
-h, --help Show this help.
Answers JSON example:
{
"selections": [
{
"clientId": "codex",
"scope": "global",
"actions": { "create-plan": "install", "web-automation": "skip" },
"helperActions": { "reviewer-runtime": "skip" }
}
]
}
Final report columns: client, scope, skill/helper, action, status, details.
`;
}
function parseArgs(argv) {
const args = { skills: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
switch (arg) {
case "--dry-run": args.dryRun = true; break;
case "--plan-only": args.planOnly = true; break;
case "--answers": args.answers = argv[++i]; break;
case "--client": args.client = argv[++i]; break;
case "--scope": args.scope = argv[++i]; break;
case "--skill": args.skills.push(argv[++i]); break;
case "--action": args.action = argv[++i]; break;
case "--yes": args.yes = true; break;
case "--pi-package": args.piPackage = true; break;
case "--reviewer": args.reviewer = argv[++i]; break;
case "-h":
case "--help": args.help = true; break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return args;
}
function renderTable(rows) {
if (!rows.length) return "No operations planned.";
const headers = ["client", "scope", "skill/helper", "action", "status", "details"];
const data = rows.map((row) => [row.client, row.scope, row.item, row.action, row.status, row.details]);
const widths = headers.map((header, index) => Math.max(header.length, ...data.map((row) => String(row[index] ?? "").length)));
const format = (row) => row.map((cell, index) => String(cell ?? "").padEnd(widths[index])).join(" ");
return [format(headers), format(widths.map((width) => "-".repeat(width))), ...data.map(format)].join("\n");
}
async function answersToPlan(answers, { assumeYes = false } = {}) {
const plan = await buildOperationPlan({ selections: answers.selections || [], assumeYes, repoRoot: process.cwd(), superpowersByClient: answers.superpowersByClient });
return plan;
}
async function buildCliSelection(args) {
if (!args.client) return null;
const clientId = args.client;
if (!CLIENTS[clientId]) throw new Error(`Unsupported client: ${clientId}`);
const scope = args.scope || (clientId === "pi" && args.piPackage ? "packageGlobal" : "global");
const scopeInfo = resolveClientScope(clientId, scope, process.cwd());
if (clientId === "pi" && scopeInfo.packageMode) {
return { selections: [{ clientId, scope, action: args.action || "install", actions: {} }] };
}
const skills = args.skills.length ? args.skills : Object.keys(SKILLS);
const action = args.action || "install";
const actions = Object.fromEntries(skills.map((skill) => [skill, action]));
return { selections: [{ clientId, scope, actions }] };
}
async function interactiveAnswers({ dryRun = false } = {}) {
const detected = await detectInstalledClients();
console.log("Detected supported clients:");
for (const info of Object.values(detected)) {
console.log(`- ${info.clientId}: ${info.confidence} (${info.detail})`);
}
if (dryRun && !input.isTTY) {
return { selections: [] };
}
const rl = readline.createInterface({ input, output });
try {
const clientLine = await rl.question("Clients to manage (comma-separated, blank for detected CLI clients): ");
const chosenClients = clientLine.trim()
? clientLine.split(",").map((value) => value.trim()).filter(Boolean)
: Object.values(detected).filter((info) => info.confidence !== "not-found").map((info) => info.clientId);
const selections = [];
for (const clientId of chosenClients) {
if (!CLIENTS[clientId]) {
console.log(`Skipping unsupported client: ${clientId}`);
continue;
}
const scopeNames = Object.keys(CLIENTS[clientId].scopes);
let scope = scopeNames[0];
if (scopeNames.length > 1) {
const answer = await rl.question(`${clientId} scope (${scopeNames.join("/")}) [${scope}]: `);
if (answer.trim()) scope = answer.trim();
}
const scopeInfo = resolveClientScope(clientId, scope, process.cwd());
if (scopeInfo.packageMode) {
console.log(`${clientId}/${scope}: package mode manages the full Pi package bundle; per-skill prompts are skipped.`);
const installed = await isPiPackageInstalled({ scope, repoRoot: process.cwd() });
const choices = "install/update/reinstall/remove/skip";
const defaultAction = installed ? "skip" : "install";
const answer = await rl.question(`${clientId}/${scope} package is ${installed ? "installed" : "not-installed"}; action (${choices}) [${defaultAction}]: `);
const chosen = answer.trim() || defaultAction;
if (!choices.split("/").includes(chosen)) {
console.log(`Invalid action '${chosen}', using skip.`);
selections.push({ clientId, scope, action: "skip", actions: {} });
} else {
selections.push({ clientId, scope, action: chosen, actions: {} });
}
continue;
}
const installed = await detectInstalledSkills({ clientId, skillsRoot: scopeInfo.skillsRoot });
const actions = {};
for (const skill of Object.keys(SKILLS)) {
const state = installed[skill]?.state || "unsupported";
if (state === "unsupported") {
console.log(`${clientId}/${scope}/${skill}: unsupported`);
actions[skill] = "skip";
continue;
}
const choices = state === "installed" || state === "stale" || state === "unknown" ? "update/reinstall/remove/skip" : "install/skip";
const defaultAction = "skip";
const answer = await rl.question(`${clientId}/${scope}/${skill} is ${state}; action (${choices}) [${defaultAction}]: `);
const chosen = answer.trim() || defaultAction;
const allowed = choices.split("/");
if (!allowed.includes(chosen)) {
console.log(`Invalid action '${chosen}', using skip.`);
actions[skill] = "skip";
} else {
actions[skill] = chosen;
}
}
const helperActions = {};
const workflowNeedsReviewerRuntime = Object.entries(actions).some(([skill, action]) => (
["install", "update", "reinstall"].includes(action) && SKILLS[skill]?.requiresReviewerRuntime
));
const helperTarget = reviewerRuntimeRoot(clientId, scopeInfo.skillsRoot, process.cwd());
const helperState = await detectHelperInstallState({
source: path.join(process.cwd(), CLIENTS[clientId].reviewerRuntime.source),
target: helperTarget,
files: CLIENTS[clientId].reviewerRuntime.files,
});
if (workflowNeedsReviewerRuntime || helperState !== "not-installed") {
const choices = helperState === "not-installed" ? "install/skip" : "update/reinstall/remove/skip";
let defaultAction = "skip";
if (workflowNeedsReviewerRuntime && helperState === "not-installed") defaultAction = "install";
if (workflowNeedsReviewerRuntime && ["stale", "unknown"].includes(helperState)) defaultAction = "update";
const answer = await rl.question(`${clientId}/${scope}/reviewer-runtime is ${helperState}; action (${choices}) [${defaultAction}]: `);
const chosen = answer.trim() || defaultAction;
const allowed = choices.split("/");
if (!allowed.includes(chosen)) {
console.log(`Invalid action '${chosen}', using skip.`);
helperActions["reviewer-runtime"] = "skip";
} else {
helperActions["reviewer-runtime"] = chosen;
}
}
selections.push({ clientId, scope, actions, helperActions });
}
return { selections };
} finally {
rl.close();
}
}
async function readAnswers(source) {
if (source === "-") {
let content = "";
for await (const chunk of input) content += chunk;
return JSON.parse(content);
}
return JSON.parse(await readFile(path.resolve(source), "utf8"));
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
if (args.reviewer) {
console.log(JSON.stringify(parseReviewerShorthand(args.reviewer), null, 2));
return;
}
let answers;
if (args.answers) {
answers = await readAnswers(args.answers);
} else {
answers = await buildCliSelection(args);
}
if (!answers) answers = await interactiveAnswers({ dryRun: args.dryRun });
const plan = await answersToPlan(answers, { assumeYes: args.yes });
if (args.planOnly) {
console.log(JSON.stringify(plan, null, 2));
return;
}
console.log("Operation preview:");
console.log(renderTable(plan.reportRows));
if (plan.prompts.length) {
console.log("\nDependency prompts:");
for (const prompt of plan.prompts) {
const promptDetail = prompt.required ? prompt.required.join(",") : (prompt.removedWorkflowSkills ? prompt.removedWorkflowSkills.join(",") : "");
console.log(`- ${prompt.kind}: ${prompt.clientId}/${prompt.scope} ${promptDetail}`);
}
}
if (plan.operations.length === 0) return;
if (args.dryRun) {
console.log("\nDry-run mode: no filesystem changes performed.");
return;
}
if (plan.prompts.length && input.isTTY && !args.yes) {
const rl = readline.createInterface({ input, output });
try {
for (const prompt of plan.prompts) {
if (prompt.kind === "missing-superpowers") {
const install = await rl.question(`Install/link Superpowers for ${prompt.clientId}/${prompt.scope}? (yes/no) [no]: `);
if (install.trim().toLowerCase() === "yes") {
const source = await rl.question("Absolute path to Obra Superpowers skills directory: ");
const modeAnswer = await rl.question("Install mode (symlink/copy) [symlink]: ");
const client = CLIENTS[prompt.clientId];
const scope = resolveClientScope(prompt.clientId, prompt.scope, process.cwd());
const target = client.superpowers.layout === "cursor-nested"
? `${scope.skillsRoot}/superpowers/skills`
: `${scope.skillsRoot}/superpowers`;
plan.operations.push({ kind: "superpowers", clientId: prompt.clientId, scope: prompt.scope, action: "install", source: source.trim(), target, skillsRoot: scope.skillsRoot, mode: modeAnswer.trim() === "copy" ? "copy" : "symlink" });
}
}
if (prompt.kind === "remove-superpowers") {
const removeAnswer = await rl.question(`Remove Superpowers for ${prompt.clientId}/${prompt.scope} too? (yes/no) [no]: `);
if (removeAnswer.trim().toLowerCase() === "yes") {
const scope = resolveClientScope(prompt.clientId, prompt.scope, process.cwd());
const target = `${scope.skillsRoot}/superpowers`;
plan.operations.push({ kind: "superpowers", clientId: prompt.clientId, scope: prompt.scope, action: "remove", target, skillsRoot: scope.skillsRoot });
}
}
}
} finally {
rl.close();
}
}
if (!args.yes) {
if (!input.isTTY) {
console.log("Refusing to execute without --yes in non-interactive mode.");
process.exitCode = 2;
return;
}
const rl = readline.createInterface({ input, output });
const answer = await rl.question("Proceed with these operations? (yes/no): ");
rl.close();
if (answer.trim().toLowerCase() !== "yes") {
console.log("Skipped by user.");
return;
}
}
const results = [];
for (const operation of plan.operations) {
try {
results.push(await executeOperation(operation));
} catch (error) {
results.push({ ...operation, status: "failed", details: error.message });
}
}
const rows = results.map((op) => ({
client: op.clientId,
scope: op.scope,
item: op.item || op.skill || op.helper || op.kind,
action: op.displayAction || op.action,
status: op.status,
details: op.details || op.target || "",
}));
console.log("\nFinal report:");
console.log(renderTable(rows));
if (results.some((result) => result.status === "failed")) process.exitCode = 1;
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
exec node "$(dirname "$0")/manage-skills.mjs" "$@"
+365
View File
@@ -0,0 +1,365 @@
/**
* Unit tests for generate-skills.mjs RED phase of TDD.
*
* Tests cover:
* - detectFileType: classification of files by extension
* - applyHeader: insertion per file-type-aware policy
* - makePackageJsonContent: unique name + private:true
* - getGeneratedRoots: returns the canonical generated-root list
*/
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
import crypto from "node:crypto";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCRIPTS_DIR = path.resolve(__dirname, "..");
const {
detectFileType,
applyHeader,
makePackageJsonContent,
getGeneratedRoots,
buildManifest,
generateSkills,
} = await import(`${SCRIPTS_DIR}/generate-skills.mjs`);
// ── detectFileType ────────────────────────────────────────────────────────
test("detectFileType: .md files → markdown", () => {
assert.equal(detectFileType("SKILL.md"), "markdown");
assert.equal(detectFileType("templates/milestone-plan.md"), "markdown");
assert.equal(detectFileType("README.md"), "markdown");
});
test("detectFileType: .sh files → shell", () => {
assert.equal(detectFileType("run-review.sh"), "shell");
assert.equal(detectFileType("scripts/install.sh"), "shell");
});
test("detectFileType: .ts and .d.ts files → ts", () => {
assert.equal(detectFileType("src/cli.ts"), "ts");
assert.equal(detectFileType("turndown-plugin-gfm.d.ts"), "ts");
assert.equal(detectFileType("auth.ts"), "ts");
});
test("detectFileType: .js files → js", () => {
assert.equal(detectFileType("check-install.js"), "js");
assert.equal(detectFileType("extract.js"), "js");
});
test("detectFileType: .json files → json", () => {
assert.equal(detectFileType("package.json"), "json");
assert.equal(detectFileType("tsconfig.json"), "json");
});
test("detectFileType: .yaml and .yml files → yaml", () => {
assert.equal(detectFileType("pnpm-lock.yaml"), "yaml");
assert.equal(detectFileType("other.yml"), "yaml");
});
test("detectFileType: .jsonc files → jsonc", () => {
assert.equal(detectFileType(".markdownlint.jsonc"), "jsonc");
});
test("detectFileType: unknown extension → unknown", () => {
assert.equal(detectFileType("Makefile"), "unknown");
assert.equal(detectFileType("somefile"), "unknown");
});
// ── applyHeader ───────────────────────────────────────────────────────────
test("applyHeader: markdown with YAML front matter inserts HTML comment after closing ---", () => {
const content = "---\nname: create-plan\n---\n\n# Create Plan\n\nBody.\n";
const result = applyHeader(content, "markdown", "skills/create-plan/_source/claude-code/SKILL.md");
// Front matter block preserved verbatim at start
assert.ok(result.startsWith("---\nname: create-plan\n---\n"), "front matter at start");
// HTML comment present
assert.ok(result.includes("<!-- ⚠️"), "HTML comment present");
// Comment comes after front matter closer and before the title
const commentIdx = result.indexOf("<!-- ⚠️");
const titleIdx = result.indexOf("# Create Plan");
assert.ok(commentIdx !== -1 && titleIdx !== -1, "both positions found");
assert.ok(commentIdx < titleIdx, "comment before title");
// Comment must NOT appear before the first ---
assert.ok(commentIdx > 3, "comment not before front matter");
});
test("applyHeader: markdown without front matter inserts HTML comment at top", () => {
const content = "# Heading\n\nContent\n";
const result = applyHeader(content, "markdown", "skills/create-plan/_source/claude-code/templates/plan.md");
assert.ok(result.startsWith("<!-- ⚠️"), "comment at very top");
assert.ok(result.includes("# Heading"), "original content preserved");
});
test("applyHeader: shell with shebang inserts # comment after shebang", () => {
const content = "#!/usr/bin/env bash\nset -euo pipefail\necho hello\n";
const result = applyHeader(content, "shell", "skills/reviewer-runtime/run-review.sh");
assert.ok(result.startsWith("#!/usr/bin/env bash\n"), "shebang preserved at top");
assert.ok(result.includes("# ⚠️"), "hash comment present");
const shebangEnd = result.indexOf("\n") + 1;
const commentStart = result.indexOf("# ⚠️");
assert.ok(commentStart === shebangEnd, "comment immediately after shebang line");
});
test("applyHeader: ts file inserts // comment at top", () => {
const content = "import path from 'path';\nexport const x = 1;\n";
const result = applyHeader(content, "ts", "skills/atlassian/shared/scripts/src/cli.ts");
assert.ok(result.startsWith("// ⚠️"), "// comment at top");
assert.ok(result.includes("import path from 'path';"), "original content preserved");
});
test("applyHeader: ts file with shebang inserts // comment after shebang", () => {
const content = "#!/usr/bin/env npx tsx\nimport foo from 'foo';\n";
const result = applyHeader(content, "ts", "auth.ts");
assert.ok(result.startsWith("#!/usr/bin/env npx tsx\n"), "shebang preserved at top");
assert.ok(result.includes("// ⚠️"), "// comment present");
const shebangEnd = result.indexOf("\n") + 1;
const commentStart = result.indexOf("// ⚠️");
assert.ok(commentStart === shebangEnd, "// comment immediately after shebang");
});
test("applyHeader: js file with shebang inserts // comment after shebang", () => {
const content = "#!/usr/bin/env node\nconst x = 1;\n";
const result = applyHeader(content, "js", "check-install.js");
assert.ok(result.startsWith("#!/usr/bin/env node\n"), "shebang preserved at top");
assert.ok(result.includes("// ⚠️"), "// comment present");
});
test("applyHeader: js file inserts // comment at top", () => {
const content = "const x = require('x');\n";
const result = applyHeader(content, "js", "check-install.js");
assert.ok(result.startsWith("// ⚠️"), "// comment at top");
});
test("applyHeader: yaml file inserts # comment at top", () => {
const content = "lockfileVersion: '9.0'\n\npackages:\n";
const result = applyHeader(content, "yaml", "pnpm-lock.yaml");
assert.ok(result.startsWith("# ⚠️"), "# comment at top");
});
test("applyHeader: jsonc file inserts // comment at top", () => {
const content = "// existing comment\n{\n \"key\": true\n}\n";
const result = applyHeader(content, "jsonc", ".markdownlint.jsonc");
assert.ok(result.startsWith("// ⚠️"), "// comment at top");
});
test("applyHeader: json file returns content unchanged (no header)", () => {
const content = '{\n "name": "test"\n}\n';
const result = applyHeader(content, "json", "package.json");
assert.equal(result, content, "JSON file must not be modified");
});
test("applyHeader: unknown type returns content unchanged", () => {
const content = "raw content\n";
const result = applyHeader(content, "unknown", "Makefile");
assert.equal(result, content);
});
test("applyHeader: header contains canonical source hint", () => {
const hint = "skills/create-plan/_source/pi/SKILL.md";
const content = "---\nname: create-plan\n---\n\n# Title\n";
const result = applyHeader(content, "markdown", hint);
assert.ok(result.includes(hint), "canonical hint appears in header");
});
test("applyHeader: never inserts header before shebang", () => {
const content = "#!/usr/bin/env bash\nset -euo pipefail\n";
const result = applyHeader(content, "shell", "run.sh");
assert.ok(result.startsWith("#!/"), "shebang still first");
});
// ── makePackageJsonContent ────────────────────────────────────────────────
test("makePackageJsonContent: renames name to scoped unique form", () => {
const src = { name: "atlassian-skill-scripts", version: "1.0.0" };
const result = makePackageJsonContent(src, "atlassian", "claude-code");
assert.equal(result.name, "@ai-coding-skills/atlassian-claude-code");
});
test("makePackageJsonContent: adds private:true", () => {
const src = { name: "web-automation-scripts", version: "1.0.0" };
const result = makePackageJsonContent(src, "web-automation", "codex");
assert.equal(result.private, true);
});
test("makePackageJsonContent: preserves all other top-level fields", () => {
const src = {
name: "atlassian-skill-scripts",
version: "1.0.0",
type: "module",
scripts: { typecheck: "tsc --noEmit" },
dependencies: { commander: "^13.1.0" },
devDependencies: { tsx: "^4.20.5" },
packageManager: "pnpm@10.18.1",
};
const result = makePackageJsonContent(src, "atlassian", "cursor");
assert.equal(result.version, "1.0.0");
assert.equal(result.type, "module");
assert.deepEqual(result.scripts, { typecheck: "tsc --noEmit" });
assert.deepEqual(result.dependencies, { commander: "^13.1.0" });
assert.equal(result.packageManager, "pnpm@10.18.1");
});
test("makePackageJsonContent: pi-package mirror uses -pi agent suffix", () => {
const src = { name: "atlassian-skill-scripts" };
const result = makePackageJsonContent(src, "atlassian", "pi");
assert.equal(result.name, "@ai-coding-skills/atlassian-pi");
});
test("makePackageJsonContent: does not mutate the source object", () => {
const src = { name: "original", version: "1.0.0" };
makePackageJsonContent(src, "atlassian", "codex");
assert.equal(src.name, "original", "source object not mutated");
});
// ── getGeneratedRoots ─────────────────────────────────────────────────────
test("getGeneratedRoots: returns at least one root per agent per skills skill", () => {
const roots = getGeneratedRoots();
assert.ok(Array.isArray(roots), "returns array");
assert.ok(roots.length > 0, "non-empty");
const agents = ["claude-code", "codex", "cursor", "opencode", "pi"];
const skillsWithAgents = ["create-plan", "do-task", "implement-plan", "atlassian", "web-automation"];
for (const skill of skillsWithAgents) {
for (const agent of agents) {
const expected = `skills/${skill}/${agent}`;
assert.ok(roots.includes(expected), `missing generated root: ${expected}`);
}
}
});
test("getGeneratedRoots: includes pi-package mirrors for all skills", () => {
const roots = getGeneratedRoots();
const piPackageSkills = ["atlassian", "create-plan", "do-task", "implement-plan", "web-automation"];
for (const skill of piPackageSkills) {
const expected = `pi-package/skills/${skill}`;
assert.ok(roots.includes(expected), `missing pi-package mirror root: ${expected}`);
}
});
test("getGeneratedRoots: includes reviewer-runtime/pi", () => {
const roots = getGeneratedRoots();
assert.ok(roots.includes("skills/reviewer-runtime/pi"), "reviewer-runtime/pi missing");
});
test("getGeneratedRoots: does not include _source or shared directories", () => {
const roots = getGeneratedRoots();
for (const r of roots) {
assert.ok(!r.includes("_source"), `root should not contain _source: ${r}`);
assert.ok(!r.endsWith("/shared"), `root should not be shared: ${r}`);
assert.ok(!r.includes("shared/scripts"), `root should not contain shared/scripts: ${r}`);
}
});
// ── buildManifest ─────────────────────────────────────────────────────────
test("buildManifest: returns object with $schema and generator markers", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "manifest-test-"));
try {
await writeFile(path.join(dir, "SKILL.md"), "---\nname: test\n---\n# Test\n");
await mkdir(path.join(dir, "templates"), { recursive: true });
await writeFile(path.join(dir, "templates", "plan.md"), "# Plan\n");
// Write .generated-manifest.json itself (should be excluded from listing)
await writeFile(path.join(dir, ".generated-manifest.json"), "{}");
const manifest = await buildManifest(dir, "skills/test/claude-code");
assert.ok(manifest.$schema, "$schema field present");
assert.ok(manifest.generator, "generator field present");
assert.equal(manifest.generatedRoot, "skills/test/claude-code");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("buildManifest: does not include .generated-manifest.json in files list", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "manifest-self-"));
try {
await writeFile(path.join(dir, "SKILL.md"), "# skill\n");
await writeFile(path.join(dir, ".generated-manifest.json"), "{}");
const manifest = await buildManifest(dir, "skills/test/pi");
const paths = manifest.files.map((f) => f.path);
assert.ok(!paths.includes(".generated-manifest.json"), "manifest must not list itself");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("buildManifest: files list includes path, kind, mode, sha256", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "manifest-fields-"));
try {
await writeFile(path.join(dir, "SKILL.md"), "# skill\n");
const manifest = await buildManifest(dir, "skills/test/codex");
assert.equal(manifest.files.length, 1);
const entry = manifest.files[0];
assert.equal(entry.path, "SKILL.md");
assert.equal(entry.kind, "file");
assert.ok(typeof entry.mode === "string" && entry.mode.match(/^\d{3}$/), "mode is 3-digit octal string");
assert.ok(typeof entry.sha256 === "string" && entry.sha256.length === 64, "sha256 is 64-char hex");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("buildManifest: files are sorted by path", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "manifest-sorted-"));
try {
await mkdir(path.join(dir, "templates"), { recursive: true });
await writeFile(path.join(dir, "SKILL.md"), "# skill\n");
await writeFile(path.join(dir, "templates", "z.md"), "z\n");
await writeFile(path.join(dir, "templates", "a.md"), "a\n");
const manifest = await buildManifest(dir, "skills/test/cursor");
const paths = manifest.files.map((f) => f.path);
const sorted = [...paths].sort();
assert.deepEqual(paths, sorted, "files must be sorted by path");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("buildManifest: sha256 matches actual file content", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "manifest-hash-"));
try {
const content = "---\nname: test\n---\n# Title\n";
await writeFile(path.join(dir, "SKILL.md"), content);
const manifest = await buildManifest(dir, "skills/test/opencode");
const expected = crypto.createHash("sha256").update(content, "utf8").digest("hex");
assert.equal(manifest.files[0].sha256, expected, "sha256 matches file content");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("generateSkills: clears pre-existing empty generated directories without EISDIR", async () => {
const targetRoot = await mkdtemp(path.join(tmpdir(), "generate-skills-target-"));
try {
await mkdir(path.join(targetRoot, "skills", "create-plan", "claude-code", "templates"), { recursive: true });
await generateSkills(path.resolve(SCRIPTS_DIR, ".."), { targetRoot });
await readFile(path.join(targetRoot, "skills", "create-plan", "claude-code", "SKILL.md"), "utf8");
await readFile(path.join(targetRoot, "skills", "create-plan", "claude-code", "templates", "milestone-plan.md"), "utf8");
} finally {
await rm(targetRoot, { recursive: true, force: true });
}
});
+139
View File
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { safeReplaceDir } from "../lib/safe-replace-dir.mjs";
// ── Happy path ────────────────────────────────────────────────────────────
test("safeReplaceDir copies source content into the target", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-copy-"));
try {
const safetyRoot = path.join(dir, "root");
const source = path.join(dir, "source");
const target = path.join(safetyRoot, "target");
await mkdir(source, { recursive: true });
await writeFile(path.join(source, "file.txt"), "hello");
await mkdir(safetyRoot, { recursive: true });
await safeReplaceDir(source, target, safetyRoot);
const content = await readFile(path.join(target, "file.txt"), "utf8");
assert.equal(content, "hello");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("safeReplaceDir removes existing content before replacing", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-stale-"));
try {
const safetyRoot = path.join(dir, "root");
const source = path.join(dir, "source");
const target = path.join(safetyRoot, "target");
await mkdir(target, { recursive: true });
await writeFile(path.join(target, "old.txt"), "stale");
await mkdir(source, { recursive: true });
await writeFile(path.join(source, "new.txt"), "fresh");
await safeReplaceDir(source, target, safetyRoot);
const files = await readdir(target);
assert.deepEqual(files.sort(), ["new.txt"]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("safeReplaceDir creates target parent directories if they do not exist", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-mkdir-"));
try {
const safetyRoot = path.join(dir, "root");
const source = path.join(dir, "source");
const target = path.join(safetyRoot, "nested", "target");
await mkdir(source, { recursive: true });
await writeFile(path.join(source, "data.txt"), "data");
await mkdir(safetyRoot, { recursive: true });
// nested parent does NOT exist yet
await safeReplaceDir(source, target, safetyRoot);
const content = await readFile(path.join(target, "data.txt"), "utf8");
assert.equal(content, "data");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("safeReplaceDir creates deeply nested parent directories (2+ levels missing)", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-deep-"));
try {
const safetyRoot = path.join(dir, "root");
const source = path.join(dir, "source");
// two parent levels (a/b) do NOT exist under safetyRoot
const target = path.join(safetyRoot, "a", "b", "target");
await mkdir(source, { recursive: true });
await writeFile(path.join(source, "deep.txt"), "deep");
await mkdir(safetyRoot, { recursive: true });
// a/ and a/b/ intentionally NOT created
await safeReplaceDir(source, target, safetyRoot);
const content = await readFile(path.join(target, "deep.txt"), "utf8");
assert.equal(content, "deep");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// ── Safety checks ─────────────────────────────────────────────────────────
test("safeReplaceDir refuses when target is outside the safety root", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-outside-"));
try {
const safetyRoot = path.join(dir, "root");
const source = path.join(dir, "source");
const outside = path.join(dir, "outside");
await mkdir(source, { recursive: true });
await mkdir(safetyRoot, { recursive: true });
await assert.rejects(
() => safeReplaceDir(source, outside, safetyRoot),
/outside safety root/,
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("safeReplaceDir refuses when target equals the safety root", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-same-"));
try {
const safetyRoot = path.join(dir, "root");
const source = path.join(dir, "source");
await mkdir(source, { recursive: true });
await mkdir(safetyRoot, { recursive: true });
await assert.rejects(
() => safeReplaceDir(source, safetyRoot, safetyRoot),
/outside safety root/,
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("safeReplaceDir refuses an empty target string", async () => {
await assert.rejects(
() => safeReplaceDir("/any", "", "/root"),
/unsafe target/,
);
});
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, rm, lstat, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { removeTarget } from "../lib/skill-manager-core.mjs";
// ── Happy path: remove existing directory ─────────────────────────────────
test("removeTarget removes an installed skill directory", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-dir-"));
try {
const skillsRoot = path.join(dir, "skills");
const target = path.join(skillsRoot, "create-plan");
await mkdir(target, { recursive: true });
await writeFile(path.join(target, "SKILL.md"), "---\nname: create-plan\n---\n");
const op = { kind: "skill", action: "remove", target, skillsRoot };
const result = await removeTarget(op);
assert.equal(result.status, "ok");
let exists = true;
try {
await lstat(target);
} catch {
exists = false;
}
assert.equal(exists, false, "target directory should be gone");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// ── Happy path: remove symbolic link ─────────────────────────────────────
test("removeTarget removes a symlink without following it", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-sym-"));
try {
const skillsRoot = path.join(dir, "skills");
const realDir = path.join(dir, "real-skill");
const target = path.join(skillsRoot, "create-plan");
await mkdir(skillsRoot, { recursive: true });
await mkdir(realDir, { recursive: true });
await writeFile(path.join(realDir, "SKILL.md"), "---\nname: create-plan\n---\n");
await symlink(realDir, target, "dir");
const op = { kind: "skill", action: "remove", target, skillsRoot };
const result = await removeTarget(op);
assert.equal(result.status, "ok");
// symlink itself should be gone
let symlinkExists = true;
try {
await lstat(target);
} catch {
symlinkExists = false;
}
assert.equal(symlinkExists, false, "symlink should be removed");
// real directory should still exist
const realStat = await lstat(realDir);
assert.ok(realStat.isDirectory(), "real directory must not be touched");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// ── Missing skill (partial state): target does not exist ─────────────────
test("removeTarget succeeds when target does not exist (idempotent)", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-missing-"));
try {
const skillsRoot = path.join(dir, "skills");
const target = path.join(skillsRoot, "create-plan");
await mkdir(skillsRoot, { recursive: true });
// target intentionally NOT created
const op = { kind: "skill", action: "remove", target, skillsRoot };
const result = await removeTarget(op);
assert.equal(result.status, "ok");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// ── Partial state: directory exists but is empty ─────────────────────────
test("removeTarget removes an empty skill directory", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-empty-"));
try {
const skillsRoot = path.join(dir, "skills");
const target = path.join(skillsRoot, "create-plan");
await mkdir(target, { recursive: true });
// directory exists but has no SKILL.md (partial install state)
const op = { kind: "skill", action: "remove", target, skillsRoot };
const result = await removeTarget(op);
assert.equal(result.status, "ok");
let exists = true;
try {
await lstat(target);
} catch {
exists = false;
}
assert.equal(exists, false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// ── Safety: refuses to remove path outside skills root ────────────────────
test("removeTarget refuses to remove a path outside the skills root", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-outside-"));
try {
const skillsRoot = path.join(dir, "skills");
const outsideTarget = path.join(dir, "outside");
await mkdir(skillsRoot, { recursive: true });
await mkdir(outsideTarget, { recursive: true });
const op = {
kind: "skill",
action: "remove",
target: outsideTarget,
skillsRoot,
};
await assert.rejects(() => removeTarget(op), /outside skills root/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+429
View File
@@ -0,0 +1,429 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CLIENTS,
SKILLS,
buildOperationPlan,
detectInstalledSkills,
findInstalledSuperpowers,
getSkillSource,
piPackageCommand,
parseReviewerShorthand,
validateRemoveTarget,
} from "../lib/skill-manager-core.mjs";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
test("manifest records supported variants and helper allowlists", () => {
assert.deepEqual(SKILLS["web-automation"].variants, ["codex", "claude-code", "cursor", "opencode", "pi"]);
assert.deepEqual(CLIENTS.codex.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
assert.deepEqual(CLIENTS.pi.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
});
test("parseReviewerShorthand keeps provider-qualified model ids verbatim", () => {
assert.deepEqual(parseReviewerShorthand("pi/claude-opus-4-7"), {
reviewerCli: "pi",
reviewerModel: "claude-opus-4-7",
});
assert.deepEqual(parseReviewerShorthand("pi/anthropic/claude-opus-4-7"), {
reviewerCli: "pi",
reviewerModel: "anthropic/claude-opus-4-7",
});
assert.equal(parseReviewerShorthand("codex/gpt-5"), null);
});
test("unsupported skill variant is reported as unsupported", () => {
assert.ok(getSkillSource("web-automation", "cursor").endsWith("skills/web-automation/cursor"));
assert.ok(getSkillSource("web-automation", "pi").endsWith("pi-package/skills/web-automation"));
});
test("detectInstalledSkills reports installed and missing skills", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-detect-"));
try {
await mkdir(path.join(dir, "skills", "create-plan"), { recursive: true });
await writeFile(path.join(dir, "skills", "create-plan", "SKILL.md"), "---\nname: create-plan\n---\n");
const state = await detectInstalledSkills({ skillsRoot: path.join(dir, "skills"), clientId: "codex" });
assert.equal(state["create-plan"].state, "installed");
assert.equal(state["web-automation"].state, "not-installed");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan install workflow skill includes reviewer-runtime and missing Superpowers prompt", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-plan-"));
try {
await mkdir(path.join(dir, "repo", "skills", "create-plan", "codex"), { recursive: true });
await writeFile(path.join(dir, "repo", "skills", "create-plan", "codex", "SKILL.md"), "---\nname: create-plan\n---\n");
const plan = await buildOperationPlan({
selections: [{ clientId: "codex", scope: "global", skillsRoot: path.join(dir, "install"), actions: { "create-plan": "install" } }],
assumeYes: true,
repoRoot: path.join(dir, "repo"),
superpowersByClient: { codex: [] },
});
assert.equal(plan.operations.some((op) => op.kind === "skill" && op.skill === "create-plan" && op.action === "install"), true);
assert.equal(plan.operations.some((op) => op.kind === "helper" && op.helper === "reviewer-runtime"), true);
assert.equal(plan.prompts.some((prompt) => prompt.kind === "missing-superpowers" && prompt.clientId === "codex"), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan skips already current reviewer-runtime helper for workflow skill updates", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-current-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true });
await mkdir(path.join(repo, "skills", "do-task", "cursor"), { recursive: true });
await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n");
await writeFile(path.join(repo, "skills", "do-task", "cursor", "SKILL.md"), "---\nname: do-task\n---\n");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update", "do-task": "update" } }],
repoRoot: repo,
superpowersByClient: { cursor: [path.join(dir, "superpowers")] },
});
const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime");
assert.equal(helperRows.length, 1);
assert.equal(helperRows[0].action, "install");
assert.equal(helperRows[0].status, "skipped");
assert.match(helperRows[0].details, /already installed/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan auto-updates stale reviewer-runtime helper for workflow skill updates", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-stale-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true });
await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}:new\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}:old\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update" } }],
repoRoot: repo,
superpowersByClient: { cursor: [path.join(dir, "superpowers")] },
});
const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime");
assert.equal(helperRows.length, 1);
assert.equal(helperRows[0].action, "update");
assert.equal(helperRows[0].status, "planned");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan honors explicit reviewer-runtime helper actions", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-explicit-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: {}, helperActions: { "reviewer-runtime": "reinstall" } }],
repoRoot: repo,
});
assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action, row.status]), [
["reviewer-runtime", "reinstall", "planned"],
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan labels skill bootstrap rows as dependency rows", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-bootstrap-label-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "web-automation", "claude-code"), { recursive: true });
await writeFile(path.join(repo, "skills", "web-automation", "claude-code", "SKILL.md"), "---\nname: web-automation\n---\n");
const plan = await buildOperationPlan({
selections: [{ clientId: "claude-code", scope: "global", skillsRoot: install, actions: { "web-automation": "update" } }],
repoRoot: repo,
});
assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action]), [
["web-automation", "update"],
["web-automation deps", "bootstrap-deps"],
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects Claude Code Superpowers plugin installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-claude-superpowers-"));
try {
const installPath = path.join(dir, ".claude", "plugins", "cache", "claude-plugins-official", "superpowers", "4.2.0");
await mkdir(path.join(installPath, "skills", "brainstorming"), { recursive: true });
await writeFile(path.join(installPath, "skills", "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
await mkdir(path.join(dir, ".claude", "plugins"), { recursive: true });
await writeFile(path.join(dir, ".claude", "settings.json"), JSON.stringify({
enabledPlugins: {
"superpowers@claude-plugins-official": true,
},
}));
await writeFile(path.join(dir, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({
plugins: {
"superpowers@claude-plugins-official": [
{
scope: "user",
installPath,
version: "4.2.0",
},
],
},
}));
assert.deepEqual(await findInstalledSuperpowers("claude-code", process.cwd(), { homeDir: dir }), [
path.join(installPath, "skills"),
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects OpenCode shared agents Superpowers installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-opencode-superpowers-"));
try {
const sharedRoot = path.join(dir, ".agents", "skills", "superpowers");
await mkdir(path.join(sharedRoot, "brainstorming"), { recursive: true });
await writeFile(path.join(sharedRoot, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
assert.deepEqual(await findInstalledSuperpowers("opencode", process.cwd(), { homeDir: dir }), [
sharedRoot,
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects Cursor Superpowers plugin installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-cursor-superpowers-"));
try {
const pluginSkills = path.join(dir, ".cursor", "plugins", "cache", "cursor-public", "superpowers", "abc123", "skills");
await mkdir(path.join(pluginSkills, "brainstorming"), { recursive: true });
await writeFile(path.join(pluginSkills, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
assert.deepEqual(await findInstalledSuperpowers("cursor", process.cwd(), { homeDir: dir }), [
pluginSkills,
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan removing last workflow skill prompts for optional Superpowers removal", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-remove-"));
try {
await mkdir(path.join(dir, "skills", "do-task"), { recursive: true });
await writeFile(path.join(dir, "skills", "do-task", "SKILL.md"), "---\nname: do-task\n---\n");
const plan = await buildOperationPlan({
selections: [{ clientId: "codex", scope: "global", skillsRoot: path.join(dir, "skills"), actions: { "do-task": "remove" } }],
assumeYes: true,
repoRoot: process.cwd(),
});
assert.equal(plan.prompts.some((prompt) => prompt.kind === "remove-superpowers" && prompt.clientId === "codex"), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode plans full package install instead of per-skill copy", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-"));
try {
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "install", actions: { "create-plan": "skip", atlassian: "remove" } }],
repoRoot: dir,
});
assert.equal(plan.operations.some((op) => op.kind === "pi-package" && op.piInstallArg === "-l"), true);
assert.equal(plan.operations.some((op) => op.kind === "skill"), false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode surfaces bundled skills and skips already installed package resources", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-installed-"));
try {
const repo = path.join(dir, "repo");
await mkdir(path.join(repo, ".pi"), { recursive: true });
await writeFile(path.join(repo, ".pi", "settings.json"), JSON.stringify({ packages: [".."] }));
for (const skill of Object.keys(SKILLS)) {
await mkdir(path.join(repo, "pi-package", "skills", skill), { recursive: true });
}
await mkdir(path.join(repo, "pi-package", "skills", "atlassian", "scripts", "node_modules"), { recursive: true });
await mkdir(path.join(repo, "pi-package", "skills", "web-automation", "scripts", "node_modules", ".bin"), { recursive: true });
await writeFile(path.join(repo, "pi-package", "skills", "web-automation", "scripts", "node_modules", ".bin", "cloakbrowser"), "");
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "install", actions: {} }],
repoRoot: repo,
});
const packageInstall = plan.operations.find((op) => op.kind === "pi-package");
assert.equal(packageInstall.status, "skipped");
assert.match(packageInstall.details, /already installed/);
assert.deepEqual(
plan.reportRows.filter((row) => row.action === "included").map((row) => row.item).sort(),
Object.keys(SKILLS).sort()
);
assert.deepEqual(
plan.reportRows.filter((row) => row.action === "bootstrap-deps").map((row) => [row.item, row.status]).sort(),
[["atlassian", "skipped"], ["web-automation", "skipped"]]
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode remove skips when package is not installed", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-remove-"));
try {
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "remove", actions: {} }],
repoRoot: dir,
});
assert.deepEqual(plan.operations.map((op) => op.kind), ["pi-package"]);
assert.equal(plan.operations[0].action, "remove");
assert.equal(plan.operations[0].status, "skipped");
assert.match(plan.operations[0].details, /not installed/);
assert.equal(plan.reportRows[0].item, "pi-package");
assert.equal(plan.reportRows[0].action, "remove");
assert.equal(plan.reportRows[0].status, "skipped");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode remove plans removal when package is installed", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-remove-installed-"));
try {
const repo = path.join(dir, "repo");
await mkdir(path.join(repo, ".pi"), { recursive: true });
await writeFile(path.join(repo, ".pi", "settings.json"), JSON.stringify({ packages: [".."] }));
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "remove", actions: {} }],
repoRoot: repo,
});
assert.deepEqual(plan.operations.map((op) => op.kind), ["pi-package"]);
assert.equal(plan.operations[0].action, "remove");
assert.equal(plan.operations[0].status, undefined);
assert.equal(plan.reportRows[0].status, "planned");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode update syncs and forces package reinstall plus dependency bootstrap", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-update-"));
try {
const repo = path.join(dir, "repo");
await mkdir(path.join(repo, ".pi"), { recursive: true });
await writeFile(path.join(repo, ".pi", "settings.json"), JSON.stringify({ packages: [".."] }));
await mkdir(path.join(repo, "pi-package", "skills", "atlassian", "scripts", "node_modules"), { recursive: true });
await mkdir(path.join(repo, "pi-package", "skills", "web-automation", "scripts", "node_modules", ".bin"), { recursive: true });
await writeFile(path.join(repo, "pi-package", "skills", "web-automation", "scripts", "node_modules", ".bin", "cloakbrowser"), "");
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "update", actions: {} }],
repoRoot: repo,
});
assert.equal(plan.operations[0].kind, "sync-pi-package");
const packageUpdate = plan.operations.find((op) => op.kind === "pi-package");
assert.equal(packageUpdate.action, "update");
assert.equal(packageUpdate.status, undefined);
assert.deepEqual(
plan.reportRows.filter((row) => row.action === "bootstrap-deps").map((row) => [row.item, row.status]).sort(),
[["atlassian", "planned"], ["web-automation", "planned"]]
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package command helper builds exact install and remove argv", () => {
assert.deepEqual(piPackageCommand({ action: "install", repoRoot: "/repo", piInstallArg: "" }), ["pi", ["install", "/repo"]]);
assert.deepEqual(piPackageCommand({ action: "update", repoRoot: "/repo", piInstallArg: "-l" }), ["pi", ["install", "-l", "/repo"]]);
assert.deepEqual(piPackageCommand({ action: "reinstall", repoRoot: "/repo", piInstallArg: "-l" }), ["pi", ["install", "-l", "/repo"]]);
assert.deepEqual(piPackageCommand({ action: "remove", repoRoot: "/repo", piInstallArg: "" }), ["pi", ["remove", "/repo"]]);
assert.deepEqual(piPackageCommand({ action: "remove", repoRoot: "/repo", piInstallArg: "-l" }), ["pi", ["remove", "-l", "/repo"]]);
});
test("cli package mode preserves package action and ignores skill narrowing", () => {
const output = execFileSync(process.execPath, [
path.join(REPO_ROOT, "scripts", "manage-skills.mjs"),
"--client", "pi",
"--scope", "packageGlobal",
"--pi-package",
"--skill", "create-plan",
"--action", "remove",
"--plan-only",
], { cwd: REPO_ROOT, encoding: "utf8" });
const plan = JSON.parse(output);
assert.deepEqual(plan.operations.map((op) => op.kind), ["pi-package"]);
assert.equal(plan.operations[0].action, "remove");
});
test("cli exits without confirmation when no operations are planned", () => {
const output = execFileSync(process.execPath, [
path.join(REPO_ROOT, "scripts", "manage-skills.mjs"),
"--answers",
"-",
], {
cwd: REPO_ROOT,
encoding: "utf8",
input: JSON.stringify({ selections: [{ clientId: "pi", scope: "packageGlobal", action: "skip", actions: {} }] }),
});
assert.match(output, /No operations planned\./);
assert.doesNotMatch(output, /Proceed with these operations/);
assert.doesNotMatch(output, /Final report/);
});
test("validateRemoveTarget rejects paths outside the manifest root", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-safe-"));
try {
await mkdir(path.join(dir, "skills", "create-plan"), { recursive: true });
await mkdir(path.join(dir, "outside"), { recursive: true });
assert.equal(await validateRemoveTarget(path.join(dir, "skills", "create-plan"), path.join(dir, "skills")), true);
await assert.rejects(() => validateRemoveTarget(path.join(dir, "outside"), path.join(dir, "skills")), /outside skills root/);
await assert.rejects(() => validateRemoveTarget(dir, dir), /refusing to remove skills root itself/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+71
View File
@@ -0,0 +1,71 @@
/**
* verify-docs-flow.test.mjs unit tests for the docs-flow verifier (M2, S-206)
*
* Tests the exported functions from scripts/verify-docs-flow.mjs.
* Each test is structured as a RED GREEN cycle: we first verify the function
* exists and behaves correctly; any structural violation surfaces as a clear
* test failure rather than a cryptic runtime error.
*/
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "../..");
// ── Helpers ────────────────────────────────────────────────────────────────
/**
* Import the verifier lazily so missing-module errors surface as test
* failures rather than crashing the whole suite.
*/
async function loadVerifier() {
const verifierPath = path.join(REPO_ROOT, "scripts", "verify-docs-flow.mjs");
return import(verifierPath);
}
// ── S-206 acceptance checks ────────────────────────────────────────────────
describe("verify-docs-flow.mjs", () => {
test("module exists and exports required functions", async () => {
const mod = await loadVerifier();
assert.equal(typeof mod.checkDocsIndexCoverage, "function",
"must export checkDocsIndexCoverage");
assert.equal(typeof mod.checkReviewerMatrixConsistency, "function",
"must export checkReviewerMatrixConsistency");
assert.equal(typeof mod.checkTelegramAgentCoverage, "function",
"must export checkTelegramAgentCoverage");
assert.equal(typeof mod.checkRepoPathsExist, "function",
"must export checkRepoPathsExist");
});
test("checkDocsIndexCoverage: every docs/*.md is linked from docs/README.md", async () => {
const { checkDocsIndexCoverage } = await loadVerifier();
const errors = await checkDocsIndexCoverage(REPO_ROOT);
assert.deepEqual(errors, [],
`docs/README.md coverage errors:\n${errors.join("\n")}`);
});
test("checkReviewerMatrixConsistency: reviewer tables consistent across canonical sources", async () => {
const { checkReviewerMatrixConsistency } = await loadVerifier();
const errors = await checkReviewerMatrixConsistency(REPO_ROOT);
assert.deepEqual(errors, [],
`Reviewer matrix inconsistency errors:\n${errors.join("\n")}`);
});
test("checkTelegramAgentCoverage: Telegram doc lists all agents with Pi helpers", async () => {
const { checkTelegramAgentCoverage } = await loadVerifier();
const errors = await checkTelegramAgentCoverage(REPO_ROOT);
assert.deepEqual(errors, [],
`Telegram coverage errors:\n${errors.join("\n")}`);
});
test("checkRepoPathsExist: all repo-relative paths in README.md and docs/ exist", async () => {
const { checkRepoPathsExist } = await loadVerifier();
const errors = await checkRepoPathsExist(REPO_ROOT);
assert.deepEqual(errors, [],
`Broken repo-relative path references:\n${errors.join("\n")}`);
});
});
+348
View File
@@ -0,0 +1,348 @@
/**
* Unit tests for verify-generated.mjs RED phase of TDD.
*
* Key contract from acceptance criteria:
* - A stray file under skills/<skill>/_source/ or skills/<skill>/shared/
* does NOT cause verify:generated to flag it as stale.
* - A stray file under skills/<skill>/<agent>/ (other than
* .generated-manifest.json) DOES cause verify:generated to flag it.
*/
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCRIPTS_DIR = path.resolve(__dirname, "..");
const { verifyGenerated } = await import(`${SCRIPTS_DIR}/verify-generated.mjs`);
// ── Stray-file detection boundary tests ──────────────────────────────────
test("verifyGenerated: stray file in _source/ is NOT flagged as stale", async () => {
// This uses the real repo's canonical source - if stray file under _source
// is added, verify-generated should not complain about it.
// We test this by verifying the function signature accepts a repoRoot and
// generatedRoots override, and that the verifier only walks declared roots.
// (Full integration test against the real repo runs in pnpm run verify:generated)
const dir = await mkdtemp(path.join(tmpdir(), "vg-stray-source-"));
try {
// Create a fake skill structure with _source/ and a generated root
const skillName = "test-skill";
const agentName = "claude-code";
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
const agentDir = path.join(dir, "skills", skillName, agentName);
const sharedDir = path.join(dir, "skills", skillName, "shared");
await mkdir(sourceDir, { recursive: true });
await mkdir(sharedDir, { recursive: true });
await mkdir(agentDir, { recursive: true });
const skillContent = "---\nname: test-skill\n---\n\n# Test Skill\n";
await writeFile(path.join(sourceDir, "SKILL.md"), skillContent);
// Add a STRAY file in _source/ — this should NOT trigger stale detection
await writeFile(path.join(sourceDir, "STRAY.md"), "stray content");
// Add a STRAY file in shared/ — this should NOT trigger stale detection
await writeFile(path.join(sharedDir, "shared-stray.txt"), "shared stray");
// The generated root is EMPTY (no manifest, no files) — but we're testing
// that _source/ and shared/ stray files don't appear in stale detection.
// We can test this indirectly: verifyGenerated with no declared roots for
// this dir returns no stale errors about _source/ or shared/.
const result = await verifyGenerated(dir, {
// Override generated roots to be empty for this minimal test
generatedRootsOverride: [],
});
// No stale errors from _source/ or shared/
const sourceErrors = result.errors.filter(
(e) => e.includes("_source") || e.includes("shared"),
);
assert.equal(sourceErrors.length, 0, `unexpected stale errors from _source/shared: ${JSON.stringify(sourceErrors)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("verifyGenerated: stray file in agent dir IS flagged as stale", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "vg-stray-agent-"));
try {
const skillName = "test-skill";
const agentName = "claude-code";
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
const agentDir = path.join(dir, "skills", skillName, agentName);
await mkdir(sourceDir, { recursive: true });
await mkdir(agentDir, { recursive: true });
const skillContent = "---\nname: test-skill\n---\n\n# Test Skill\n";
await writeFile(path.join(sourceDir, "SKILL.md"), skillContent);
// Generate the agent dir with proper content + manifest
const headerLine =
`<!-- ⚠️ GENERATED FILE do not edit directly. ` +
`Edit the canonical source in skills/${skillName}/_source/${agentName}/SKILL.md ` +
`and run \`pnpm run sync:pi\`. -->`;
const generatedContent = `---\nname: test-skill\n---\n\n${headerLine}\n\n# Test Skill\n`;
await writeFile(path.join(agentDir, "SKILL.md"), generatedContent);
// Add a STRAY file in the agent dir — this SHOULD be flagged
await writeFile(path.join(agentDir, "STRAY.md"), "stray content");
// Write a manifest that does NOT include STRAY.md
const { buildManifest } = await import(`${SCRIPTS_DIR}/generate-skills.mjs`);
const manifest = await buildManifest(agentDir, `skills/${skillName}/${agentName}`);
// Remove STRAY.md from manifest (simulate pre-stray-add manifest)
manifest.files = manifest.files.filter((f) => f.path !== "STRAY.md");
await writeFile(
path.join(agentDir, ".generated-manifest.json"),
JSON.stringify(manifest, null, 2) + "\n",
);
const result = await verifyGenerated(dir, {
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
});
assert.equal(result.ok, false, "should fail when stray file present");
const strayError = result.errors.some((e) => e.includes("STRAY.md"));
assert.ok(strayError, `STRAY.md should appear in errors: ${JSON.stringify(result.errors)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("verifyGenerated: .generated-manifest.json is excluded from stale-file detection", async () => {
// Even though .generated-manifest.json is in the generated root, it should
// not be considered a "stale file" just because it's not in the files list
const dir = await mkdtemp(path.join(tmpdir(), "vg-manifest-self-"));
try {
const skillName = "test-skill";
const agentName = "pi";
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
const agentDir = path.join(dir, "skills", skillName, agentName);
await mkdir(sourceDir, { recursive: true });
await mkdir(agentDir, { recursive: true });
const skillContent = "---\nname: test-skill\n---\n\n# Test Skill\n";
await writeFile(path.join(sourceDir, "SKILL.md"), skillContent);
const headerLine =
`<!-- ⚠️ GENERATED FILE do not edit directly. ` +
`Edit the canonical source in skills/${skillName}/_source/${agentName}/SKILL.md ` +
`and run \`pnpm run sync:pi\`. -->`;
const generatedContent = `---\nname: test-skill\n---\n\n${headerLine}\n\n# Test Skill\n`;
await writeFile(path.join(agentDir, "SKILL.md"), generatedContent);
// Write manifest (will include SKILL.md, not itself)
const { buildManifest } = await import(`${SCRIPTS_DIR}/generate-skills.mjs`);
const manifest = await buildManifest(agentDir, `skills/${skillName}/${agentName}`);
await writeFile(
path.join(agentDir, ".generated-manifest.json"),
JSON.stringify(manifest, null, 2) + "\n",
);
const result = await verifyGenerated(dir, {
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
});
// Should pass — .generated-manifest.json is excluded from stale detection
const manifestErrors = result.errors.filter((e) => e.includes(".generated-manifest.json"));
assert.equal(manifestErrors.length, 0, `manifest file should not appear as stale: ${JSON.stringify(manifestErrors)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("verifyGenerated: missing file from manifest is flagged as deleted", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "vg-missing-file-"));
try {
const skillName = "test-skill";
const agentName = "cursor";
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
const agentDir = path.join(dir, "skills", skillName, agentName);
await mkdir(sourceDir, { recursive: true });
await mkdir(agentDir, { recursive: true });
const skillContent = "---\nname: test-skill\n---\n\n# Test Skill\n";
await writeFile(path.join(sourceDir, "SKILL.md"), skillContent);
await writeFile(path.join(agentDir, "SKILL.md"), "generated content\n");
// Manifest claims templates/plan.md exists, but the file doesn't
const manifest = {
$schema: "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
generator: "scripts/generate-skills.mjs",
generatedRoot: `skills/${skillName}/${agentName}`,
files: [
{ path: "SKILL.md", kind: "file", mode: "644", sha256: "aaa" },
{ path: "templates/plan.md", kind: "file", mode: "644", sha256: "bbb" },
],
};
await writeFile(
path.join(agentDir, ".generated-manifest.json"),
JSON.stringify(manifest, null, 2) + "\n",
);
const result = await verifyGenerated(dir, {
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
});
assert.equal(result.ok, false, "should fail on missing file");
const missingError = result.errors.some((e) => e.includes("templates/plan.md"));
assert.ok(missingError, `missing file should appear in errors: ${JSON.stringify(result.errors)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("verifyGenerated: content mismatch is flagged", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "vg-content-mismatch-"));
try {
const skillName = "test-skill";
const agentName = "opencode";
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
const agentDir = path.join(dir, "skills", skillName, agentName);
await mkdir(sourceDir, { recursive: true });
await mkdir(agentDir, { recursive: true });
await writeFile(path.join(sourceDir, "SKILL.md"), "---\nname: test-skill\n---\n\n# Original\n");
// Agent dir has DIFFERENT content than what manifest says
await writeFile(path.join(agentDir, "SKILL.md"), "---\nname: test-skill\n---\n\n# Modified!\n");
const manifest = {
$schema: "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
generator: "scripts/generate-skills.mjs",
generatedRoot: `skills/${skillName}/${agentName}`,
files: [
{
path: "SKILL.md",
kind: "file",
mode: "644",
// SHA of the ORIGINAL content (not what's on disk)
sha256: "0000000000000000000000000000000000000000000000000000000000000000",
},
],
};
await writeFile(
path.join(agentDir, ".generated-manifest.json"),
JSON.stringify(manifest, null, 2) + "\n",
);
const result = await verifyGenerated(dir, {
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
});
assert.equal(result.ok, false, "should fail on content mismatch");
const mismatchError = result.errors.some((e) => e.includes("SKILL.md"));
assert.ok(mismatchError, `content mismatch should appear in errors: ${JSON.stringify(result.errors)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("verifyGenerated: manifest entry with wrong sha256 is flagged even when paths match", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "vg-manifest-sha-"));
try {
const skillName = "test-skill";
const agentName = "codex";
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
const agentDir = path.join(dir, "skills", skillName, agentName);
await mkdir(sourceDir, { recursive: true });
await mkdir(agentDir, { recursive: true });
const content = "---\nname: test-skill\n---\n\n# Test Skill\n";
await writeFile(path.join(sourceDir, "SKILL.md"), content);
await writeFile(path.join(agentDir, "SKILL.md"), content);
// Manifest has correct path but deliberately wrong sha256 (simulates corrupted metadata)
const manifest = {
$schema: "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
generator: "scripts/generate-skills.mjs",
generatedRoot: `skills/${skillName}/${agentName}`,
files: [
{
path: "SKILL.md",
kind: "file",
mode: "644",
sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
},
],
};
await writeFile(
path.join(agentDir, ".generated-manifest.json"),
JSON.stringify(manifest, null, 2) + "\n",
);
const result = await verifyGenerated(dir, {
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
});
// Even though file paths match, the sha256 mismatch in the manifest should be detected
assert.equal(result.ok, false, "should fail on sha256 mismatch in manifest");
const sha256Error = result.errors.some(
(e) => e.includes("sha256") || e.includes("SKILL.md"),
);
assert.ok(sha256Error, `sha256 mismatch should appear in errors: ${JSON.stringify(result.errors)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("verifyGenerated: manifest entry with wrong mode is flagged even when paths match", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "vg-manifest-mode-"));
try {
const skillName = "test-skill";
const agentName = "cursor";
const agentDir = path.join(dir, "skills", skillName, agentName);
await mkdir(agentDir, { recursive: true });
const content = "# Test Skill\n";
await writeFile(path.join(agentDir, "SKILL.md"), content);
// Build a correct manifest first (with real sha256)
const { buildManifest } = await import(`${SCRIPTS_DIR}/generate-skills.mjs`);
const correctManifest = await buildManifest(agentDir, `skills/${skillName}/${agentName}`);
// Tamper: change the mode field for the SKILL.md entry
const tamperedManifest = {
...correctManifest,
files: correctManifest.files.map((f) =>
f.path === "SKILL.md" ? { ...f, mode: "777" } : f,
),
};
await writeFile(
path.join(agentDir, ".generated-manifest.json"),
JSON.stringify(tamperedManifest, null, 2) + "\n",
);
const result = await verifyGenerated(dir, {
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
});
// The mode mismatch in the manifest should be detected by diffManifests
assert.equal(result.ok, false, "should fail on mode mismatch in manifest");
const modeError = result.errors.some(
(e) => e.includes("mode") || e.includes("SKILL.md"),
);
assert.ok(modeError, `mode mismatch should appear in errors: ${JSON.stringify(result.errors)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env node
/**
* verify-docs-flow.mjs documentation reading-flow and consistency verifier (M2, S-206)
*
* Asserts:
* (i) every docs/*.md file is linked from docs/README.md
* (ii) the reviewer CLI matrix is consistent across the four canonical sources
* (iii) the Telegram agent list matches the set of agents with helpers on disk
* (iv) all repository-relative paths referenced in README.md and docs/ exist
*
* Exits 0 when all checks pass; exits 1 with a report when any check fails.
*
* Usage:
* node scripts/verify-docs-flow.mjs
* pnpm run verify:docs (wired via package.json)
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const REPO_ROOT = path.resolve(__dirname, "..");
// ── (i) docs/README.md coverage ───────────────────────────────────────────
/**
* Check that every *.md file under docs/ is linked from docs/README.md.
* Returns an array of error strings (empty = pass).
*
* @param {string} repoRoot
* @returns {Promise<string[]>}
*/
export async function checkDocsIndexCoverage(repoRoot) {
const docsDir = path.join(repoRoot, "docs");
const readmePath = path.join(docsDir, "README.md");
if (!fs.existsSync(readmePath)) {
return ["docs/README.md is missing"];
}
const readmeContent = fs.readFileSync(readmePath, "utf8");
const errors = [];
// Collect all *.md files under docs/ except README.md itself
const mdFiles = fs
.readdirSync(docsDir)
.filter((f) => f.endsWith(".md") && f !== "README.md");
for (const file of mdFiles) {
// Accept both ./FILE.md and FILE.md link forms
const linked =
readmeContent.includes(`](${file})`) ||
readmeContent.includes(`](./${file})`);
if (!linked) {
errors.push(`docs/README.md does not link to docs/${file}`);
}
}
return errors;
}
// ── (ii) Reviewer matrix consistency ──────────────────────────────────────
/**
* Canonical reviewer CLI names expected in workflow docs.
* Order matters for the consistency check.
*/
const EXPECTED_REVIEWER_CLIS = ["codex", "claude", "cursor", "opencode", "pi"];
/**
* Check that the reviewer CLI matrix is consistent across the four canonical
* workflow docs. Returns an array of error strings (empty = pass).
*
* @param {string} repoRoot
* @returns {Promise<string[]>}
*/
export async function checkReviewerMatrixConsistency(repoRoot) {
const canonical = [
"docs/CREATE-PLAN.md",
"docs/IMPLEMENT-PLAN.md",
"docs/DO-TASK.md",
"docs/REVIEWERS.md",
];
const errors = [];
for (const docPath of canonical) {
const fullPath = path.join(repoRoot, docPath);
if (!fs.existsSync(fullPath)) {
errors.push(`${docPath} is missing`);
continue;
}
const content = fs.readFileSync(fullPath, "utf8");
// Each canonical doc must reference all five CLIs
for (const cli of EXPECTED_REVIEWER_CLIS) {
if (!content.includes(`\`${cli}\``)) {
errors.push(`${docPath}: does not mention reviewer CLI \`${cli}\``);
}
}
}
return errors;
}
// ── (iii) Telegram agent coverage ─────────────────────────────────────────
/**
* Agents that must have helpers on disk in skills/reviewer-runtime/.
* Non-Pi agents use the top-level directory; Pi uses the pi/ sub-directory.
*/
const TELEGRAM_AGENTS = [
{
id: "codex",
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
telegramSectionKeyword: "Codex",
},
{
id: "claude-code",
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
telegramSectionKeyword: "Claude Code",
},
{
id: "opencode",
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
telegramSectionKeyword: "OpenCode",
},
{
id: "cursor",
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
telegramSectionKeyword: "Cursor",
},
{
id: "pi",
helperPath: "skills/reviewer-runtime/pi/notify-telegram.sh",
telegramSectionKeyword: "Pi",
},
];
/**
* Check that docs/TELEGRAM-NOTIFICATIONS.md documents every agent that has a
* notify-telegram.sh helper on disk. Returns an array of error strings.
*
* @param {string} repoRoot
* @returns {Promise<string[]>}
*/
export async function checkTelegramAgentCoverage(repoRoot) {
const telegramDoc = path.join(repoRoot, "docs", "TELEGRAM-NOTIFICATIONS.md");
const errors = [];
if (!fs.existsSync(telegramDoc)) {
return ["docs/TELEGRAM-NOTIFICATIONS.md is missing"];
}
const content = fs.readFileSync(telegramDoc, "utf8");
for (const agent of TELEGRAM_AGENTS) {
const helperFullPath = path.join(repoRoot, agent.helperPath);
if (!fs.existsSync(helperFullPath)) {
errors.push(
`Telegram agent ${agent.id}: helper ${agent.helperPath} does not exist on disk`
);
continue;
}
// The Telegram doc must mention this agent somewhere
if (!content.includes(agent.telegramSectionKeyword)) {
errors.push(
`docs/TELEGRAM-NOTIFICATIONS.md does not document agent: ${agent.telegramSectionKeyword}`
);
}
}
// Pi-specific: must link to PI-COMMON-REVIEWER.md
if (!content.includes("PI-COMMON-REVIEWER.md")) {
errors.push(
"docs/TELEGRAM-NOTIFICATIONS.md must link to docs/PI-COMMON-REVIEWER.md"
);
}
return errors;
}
// ── (iv) Repo-relative path references ────────────────────────────────────
/**
* Patterns to skip when checking path references these are URLs and
* non-filesystem references that look like repo paths but aren't.
*/
const PATH_SKIP_PATTERNS = [
/^https?:\/\//, // HTTP URLs
/^\/tmp\//, // /tmp paths (runtime artifacts)
/^\$\{/, // shell variable expansions
/^~\//, // home directory paths
/^\.\.?\//, // relative ./ or ../ — checked differently
/node_modules/, // node_modules
/^[A-Z_]+_[A-Z_]+$/, // environment variable names
];
/**
* Extract candidate repository-relative paths from markdown text.
* Looks for:
* - markdown links: [text](path)
* - inline code: `path`
* - bare paths starting with known top-level directories
*
* @param {string} content
* @returns {string[]} - candidate relative paths
*/
function extractRepoPaths(content) {
const candidates = new Set();
// Markdown links: [text](path) where path does not start with http/https
const linkRe = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
let m;
while ((m = linkRe.exec(content)) !== null) {
const href = m[1].split("#")[0].trim(); // strip anchors
if (!href) continue;
if (PATH_SKIP_PATTERNS.some((p) => p.test(href))) continue;
// Normalize ./ prefix
const normalized = href.startsWith("./") ? href.slice(2) : href;
if (normalized.startsWith("../")) continue; // skip upward traversals
candidates.add(normalized);
}
return [...candidates];
}
/**
* Check that all repository-relative paths referenced in README.md and docs/
* actually exist in the repository. Returns an array of error strings.
*
* @param {string} repoRoot
* @returns {Promise<string[]>}
*/
export async function checkRepoPathsExist(repoRoot) {
const errors = [];
const filesToCheck = [
path.join(repoRoot, "README.md"),
...fs
.readdirSync(path.join(repoRoot, "docs"))
.filter((f) => f.endsWith(".md"))
.map((f) => path.join(repoRoot, "docs", f)),
];
for (const filePath of filesToCheck) {
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, "utf8");
const relDoc = path.relative(repoRoot, filePath);
const candidates = extractRepoPaths(content);
for (const candidate of candidates) {
// Skip things that obviously aren't repo paths
if (!candidate || candidate.length < 3) continue;
// Skip paths that look like markdown-only links (heading anchors)
if (candidate.startsWith("#")) continue;
// For links relative to docs/, resolve relative to docs/
let candidatePath;
if (relDoc.startsWith("docs/")) {
candidatePath = path.join(repoRoot, "docs", candidate);
// If not found there, try relative to repo root
if (!fs.existsSync(candidatePath)) {
candidatePath = path.join(repoRoot, candidate);
}
} else {
candidatePath = path.join(repoRoot, candidate);
}
if (!fs.existsSync(candidatePath)) {
errors.push(`${relDoc}: broken repo path reference → ${candidate}`);
}
}
}
return errors;
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
const repoRoot = REPO_ROOT;
const allErrors = [];
const checks = [
["docs/README.md coverage", checkDocsIndexCoverage],
["reviewer matrix consistency", checkReviewerMatrixConsistency],
["Telegram agent coverage", checkTelegramAgentCoverage],
["repo-relative path existence", checkRepoPathsExist],
];
for (const [label, fn] of checks) {
const errors = await fn(repoRoot);
if (errors.length > 0) {
console.error(`\n[FAIL] ${label}:`);
for (const e of errors) {
console.error(` - ${e}`);
}
allErrors.push(...errors);
} else {
console.log(`[pass] ${label}`);
}
}
if (allErrors.length > 0) {
console.error(`\ndocs-flow: ${allErrors.length} error(s) found.`);
process.exit(1);
} else {
console.log("\ndocs-flow: all checks passed.");
process.exit(0);
}
}
// Run main only when executed directly (not when imported by tests)
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
if (isMain) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
+421
View File
@@ -0,0 +1,421 @@
#!/usr/bin/env node
/**
* verify-generated.mjs drift detector for generator-owned files (M3, S-306)
*
* Verifies that every declared generated root on disk matches the content that
* the generator would produce from the current canonical sources.
*
* Two verification modes:
*
* Production mode (default, no generatedRootsOverride):
* Calls generateSkills() into a temp directory and compares the freshly
* generated output file-by-file against the on-disk generated roots.
* Detects ALL forms of drift:
* (a) Direct edits to generated files
* (b) Canonical source changes without running `pnpm run sync:pi`
* (c) Stale files added to a generated root
* (d) Files deleted from a generated root
*
* Test mode (generatedRootsOverride set):
* Uses `.generated-manifest.json` as the oracle (manifest-based checks).
* Suitable for unit tests that use artificial generated-root fixtures
* without a full canonical source tree.
*
* Walk scope: only the declared generated roots returned by generate-skills.mjs.
* - `skills/<skill>/_source/` and `skills/<skill>/shared/` are NEVER walked.
* - `.generated-manifest.json` is excluded from content comparison.
* - `node_modules/` is always excluded.
*
* Exit codes:
* 0 all generated roots match
* 1 one or more mismatches detected
*
* Usage:
* node scripts/verify-generated.mjs
* pnpm run verify:generated
*
* Exported:
* verifyGenerated(repoRoot, options?) Promise<{ok: boolean, errors: string[]}>
*/
import crypto from "node:crypto";
import { lstat, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { buildManifest, generateSkills, getGeneratedRoots } from "./generate-skills.mjs";
const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const MANIFEST_FILENAME = ".generated-manifest.json";
// ── Helpers ───────────────────────────────────────────────────────────────
async function sha256File(absPath) {
const buf = await readFile(absPath);
return crypto.createHash("sha256").update(buf).digest("hex");
}
async function pathExists(p) {
try {
await lstat(p);
return true;
} catch {
return false;
}
}
/**
* Walk a directory and return all file relative paths (excluding node_modules
* and the manifest file itself, which is handled separately).
*/
async function walkRootFiles(rootDir) {
const results = [];
let entries;
try {
entries = await readdir(rootDir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (entry.name === "node_modules") continue;
if (entry.name === MANIFEST_FILENAME) continue; // manifest handled separately
const full = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
const sub = await walkSubDir(full, entry.name);
results.push(...sub);
} else if (entry.isFile()) {
results.push(entry.name);
}
}
return results;
}
async function walkSubDir(dir, prefix) {
const results = [];
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (entry.name === "node_modules") continue;
const relPath = `${prefix}/${entry.name}`;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const sub = await walkSubDir(full, relPath);
results.push(...sub);
} else if (entry.isFile()) {
results.push(relPath);
}
}
return results;
}
/**
* Load and parse the .generated-manifest.json from a generated root.
* Returns null if missing or malformed.
*/
async function loadManifest(rootDir) {
const manifestPath = path.join(rootDir, MANIFEST_FILENAME);
try {
const raw = await readFile(manifestPath, "utf8");
const obj = JSON.parse(raw);
// Structural validation: must have $schema and generator markers
if (!obj.$schema || !obj.generator || !Array.isArray(obj.files)) {
return null;
}
return obj;
} catch {
return null;
}
}
/**
* Verify structural equality of two manifests (ignoring ephemeral fields).
* Returns array of difference descriptions, or empty array if equal.
*/
function diffManifests(expected, actual, rootRel) {
const diffs = [];
if (expected.$schema !== actual.$schema) {
diffs.push(`${rootRel}/.generated-manifest.json: $schema mismatch`);
}
if (expected.generator !== actual.generator) {
diffs.push(`${rootRel}/.generated-manifest.json: generator mismatch`);
}
if (expected.generatedRoot !== actual.generatedRoot) {
diffs.push(
`${rootRel}/.generated-manifest.json: generatedRoot mismatch ` +
`(expected ${expected.generatedRoot}, got ${actual.generatedRoot})`,
);
}
// Compare files arrays structurally — paths, kind, mode, and sha256
const expByPath = new Map(expected.files.map((f) => [f.path, f]));
const actByPath = new Map(actual.files.map((f) => [f.path, f]));
for (const p of expByPath.keys()) {
if (!actByPath.has(p)) {
diffs.push(`${rootRel}/.generated-manifest.json: expected file entry missing: ${p}`);
}
}
for (const p of actByPath.keys()) {
if (!expByPath.has(p)) {
diffs.push(`${rootRel}/.generated-manifest.json: unexpected file entry: ${p}`);
}
}
// For entries present in both, compare kind, mode, and sha256
for (const [p, expEntry] of expByPath) {
const actEntry = actByPath.get(p);
if (!actEntry) continue; // missing already reported above
if (expEntry.kind !== actEntry.kind) {
diffs.push(
`${rootRel}/.generated-manifest.json: ${p}: kind mismatch ` +
`(expected ${expEntry.kind}, got ${actEntry.kind})`,
);
}
if (expEntry.mode !== actEntry.mode) {
diffs.push(
`${rootRel}/.generated-manifest.json: ${p}: mode mismatch ` +
`(expected ${expEntry.mode}, got ${actEntry.mode})`,
);
}
if (expEntry.sha256 !== actEntry.sha256) {
diffs.push(
`${rootRel}/.generated-manifest.json: ${p}: sha256 mismatch ` +
`(expected ${expEntry.sha256.slice(0, 8)}…, got ${actEntry.sha256.slice(0, 8)}…)`,
);
}
}
return diffs;
}
// ── Core verifier ─────────────────────────────────────────────────────────
/**
* Verify all declared generated roots against freshly generated output.
*
* Calls generateSkills() into a temp directory and compares the resulting
* files to the on-disk roots. Detects all drift types:
* - Canonical source changed without running `pnpm run sync:pi`
* - Generated file edited directly
* - Stale file added to generated root
* - Generated file deleted from root
*
* Does NOT walk _source/, shared/, or node_modules/.
*
* @param {string} repoRoot - Absolute path to repo root.
* @param {string[]} rootsRelative - Generated root paths to verify.
* @param {string[]} errors - Error array to append to.
*/
async function verifyWithFreshGeneration(repoRoot, rootsRelative, errors) {
const tmpDir = await mkdtemp(path.join(tmpdir(), "verify-gen-"));
try {
await generateSkills(repoRoot, { targetRoot: tmpDir });
for (const rootRel of rootsRelative) {
const freshRootAbs = path.join(tmpDir, rootRel);
const onDiskRootAbs = path.join(repoRoot, rootRel);
if (!(await pathExists(onDiskRootAbs))) {
errors.push(`generated root missing: ${rootRel}`);
continue;
}
const freshFiles = await walkRootFiles(freshRootAbs);
const onDiskFiles = await walkRootFiles(onDiskRootAbs);
const freshSet = new Set(freshFiles);
const diskSet = new Set(onDiskFiles);
// Files the generator produces but are absent on disk
for (const f of freshFiles) {
if (!diskSet.has(f)) {
errors.push(`${rootRel}/${f}: missing (expected per canonical sources — run \`pnpm run sync:pi\`)`);
}
}
// On-disk files the generator would NOT produce (stale)
for (const f of onDiskFiles) {
if (!freshSet.has(f)) {
errors.push(`${rootRel}/${f}: stale (not produced from canonical sources)`);
}
}
// Content comparison for files present in both sets
for (const f of freshFiles) {
if (!diskSet.has(f)) continue; // missing already reported
const freshHash = await sha256File(path.join(freshRootAbs, f));
const diskHash = await sha256File(path.join(onDiskRootAbs, f));
if (freshHash !== diskHash) {
errors.push(
`${rootRel}/${f}: content drift ` +
`(on-disk sha256=${diskHash.slice(0, 8)}…, expected=${freshHash.slice(0, 8)}… — run \`pnpm run sync:pi\`)`,
);
}
}
// Validate .generated-manifest.json on disk matches what the generator produced
const freshManifestPath = path.join(freshRootAbs, MANIFEST_FILENAME);
const diskManifestPath = path.join(onDiskRootAbs, MANIFEST_FILENAME);
if (await pathExists(freshManifestPath)) {
if (!(await pathExists(diskManifestPath))) {
errors.push(`${rootRel}/${MANIFEST_FILENAME}: missing`);
} else {
const freshManifest = await loadManifest(freshRootAbs);
const diskManifest = await loadManifest(onDiskRootAbs);
if (!diskManifest) {
errors.push(`${rootRel}/${MANIFEST_FILENAME}: missing or malformed`);
} else if (freshManifest) {
const manifestDiffs = diffManifests(freshManifest, diskManifest, rootRel);
errors.push(...manifestDiffs);
}
}
}
}
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
/**
* Verify using .generated-manifest.json as the oracle (test / no-canonical-sources mode).
*
* Used when generatedRootsOverride is set: the test fixture creates generated
* roots with manifests but does not provide a full canonical source tree.
*
* For each root:
* 1. Load the on-disk .generated-manifest.json (report missing manifest).
* 2. For every file listed in the manifest: verify existence, mode, sha256.
* 3. Walk the root for any files NOT listed in the manifest (stale files).
* 4. Verify the manifest's own structural fields match expected schema.
*
* @param {string} repoRoot - Absolute path to repo root.
* @param {string[]} rootsRelative - Generated root paths to verify.
* @param {string[]} errors - Error array to append to.
*/
async function verifyWithManifest(repoRoot, rootsRelative, errors) {
for (const rootRel of rootsRelative) {
const rootAbs = path.join(repoRoot, rootRel);
// Check the root directory exists
if (!(await pathExists(rootAbs))) {
errors.push(`generated root missing: ${rootRel}`);
continue;
}
// Load on-disk manifest
const manifest = await loadManifest(rootAbs);
if (!manifest) {
errors.push(`${rootRel}/.generated-manifest.json: missing or malformed`);
// Can't verify files without a manifest; report all on-disk files as stale
const onDisk = await walkRootFiles(rootAbs);
for (const f of onDisk) {
errors.push(`${rootRel}/${f}: stale (no valid manifest)`);
}
continue;
}
// Build expected manifest from current disk state (structural check)
const expectedManifest = await buildManifest(rootAbs, rootRel);
const manifestDiffs = diffManifests(expectedManifest, manifest, rootRel);
errors.push(...manifestDiffs);
// Build maps for file checks
const manifestByPath = new Map(manifest.files.map((f) => [f.path, f]));
// Check each file listed in manifest
for (const entry of manifest.files) {
const fileAbs = path.join(rootAbs, entry.path);
if (!(await pathExists(fileAbs))) {
errors.push(`${rootRel}/${entry.path}: missing (listed in manifest)`);
continue;
}
// Check content hash
const actualHash = await sha256File(fileAbs);
if (actualHash !== entry.sha256) {
errors.push(
`${rootRel}/${entry.path}: content mismatch ` +
`(manifest sha256=${entry.sha256.slice(0, 8)}…, actual=${actualHash.slice(0, 8)}…)`,
);
}
// Check mode
const st = await lstat(fileAbs);
const actualMode = (st.mode & 0o777).toString(8).padStart(3, "0");
if (actualMode !== entry.mode) {
errors.push(
`${rootRel}/${entry.path}: mode mismatch ` +
`(manifest=${entry.mode}, actual=${actualMode})`,
);
}
}
// Detect stale files: on-disk files not listed in manifest
const onDiskFiles = await walkRootFiles(rootAbs);
for (const relPath of onDiskFiles) {
if (!manifestByPath.has(relPath)) {
errors.push(`${rootRel}/${relPath}: stale (not in manifest)`);
}
}
}
}
/**
* Verify all declared generated roots in a repo.
*
* In production mode (no generatedRootsOverride): generates into a temp
* directory and compares file-by-file against on-disk roots. This detects
* both direct edits to generated files AND changes to canonical sources that
* were not followed by `pnpm run sync:pi`.
*
* In test mode (generatedRootsOverride set): uses manifest-based checks.
* Suitable for unit tests that provide artificial generated-root fixtures
* without a full canonical source tree.
*
* @param {string} [repoRoot] - Absolute path to repo root.
* @param {object} [options]
* @param {string[]} [options.generatedRootsOverride] - Override root list (test mode only).
* @returns {Promise<{ok: boolean, errors: string[]}>}
*/
export async function verifyGenerated(repoRoot = REPO_ROOT, options = {}) {
const rootsRelative = options.generatedRootsOverride ?? getGeneratedRoots();
const errors = [];
if (options.generatedRootsOverride) {
// Test mode: no canonical source tree available — use manifest oracle
await verifyWithManifest(repoRoot, rootsRelative, errors);
} else {
// Production mode: generate fresh from canonical sources and compare
await verifyWithFreshGeneration(repoRoot, rootsRelative, errors);
}
return { ok: errors.length === 0, errors };
}
// ── CLI entry point ────────────────────────────────────────────────────────
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const result = await verifyGenerated(REPO_ROOT);
if (result.ok) {
console.log("verify:generated: all generated roots are up to date.");
process.exit(0);
} else {
console.error("verify:generated: drift detected:\n");
for (const err of result.errors) {
console.error(`${err}`);
}
console.error(`\n${result.errors.length} error(s). Run \`pnpm run sync:pi\` to regenerate.`);
process.exit(1);
}
}
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=lib/portable.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib/portable.sh"
REQUIRED_FILES=(
"docs/PI-RESEARCH.md"
"docs/PI.md"
"docs/PI-SUPERPOWERS.md"
"docs/PI-COMMON-REVIEWER.md"
"skills/atlassian/pi/SKILL.md"
"skills/create-plan/pi/SKILL.md"
"skills/create-plan/pi/templates/continuation-runbook.md"
"skills/create-plan/pi/templates/milestone-plan.md"
"skills/create-plan/pi/templates/story-tracker.md"
"skills/do-task/pi/SKILL.md"
"skills/do-task/pi/templates/task-plan.md"
"skills/implement-plan/pi/SKILL.md"
"skills/web-automation/pi/SKILL.md"
"skills/reviewer-runtime/pi/run-review.sh"
"skills/reviewer-runtime/pi/notify-telegram.sh"
"scripts/install-pi-package.sh"
"pi-package/skills/atlassian/SKILL.md"
"pi-package/skills/create-plan/SKILL.md"
"pi-package/skills/do-task/SKILL.md"
"pi-package/skills/implement-plan/SKILL.md"
"pi-package/skills/web-automation/SKILL.md"
"package.json"
)
# These required-file checks are intentionally hard failures: removing any
# required artifact should make this script exit non-zero immediately.
for file in "${REQUIRED_FILES[@]}"; do
test -f "$file"
done
test -x skills/reviewer-runtime/pi/run-review.sh
test -x skills/reviewer-runtime/pi/notify-telegram.sh
test -x scripts/install-pi-package.sh
find skills/web-automation/pi/scripts -type f -print -quit | grep -q .
find skills/atlassian/pi/scripts -type f -print -quit | grep -q .
for file in skills/create-plan/pi/SKILL.md skills/do-task/pi/SKILL.md skills/implement-plan/pi/SKILL.md; do
grep -q 'docs/PI-SUPERPOWERS.md' "$file"
grep -q 'docs/PI-COMMON-REVIEWER.md' "$file"
# shellcheck disable=SC2016
grep -q 'Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`' "$file"
grep -q 'pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files' "$file"
grep -q -- '--tools read,grep,find,ls -p' "$file"
grep -q 'pi --list-models \[search\]' "$file"
done
grep -q 'reviewer model is configured independently' docs/PI-COMMON-REVIEWER.md
grep -q 'provider-qualified model IDs' docs/PI-COMMON-REVIEWER.md
# shellcheck disable=SC2016
grep -q 'MUST NOT include `write`, `edit`, or `bash`' docs/PI-COMMON-REVIEWER.md
grep -q 'Reviewer CLI | codex \\| claude \\| cursor \\| opencode \\| pi' skills/do-task/pi/templates/task-plan.md
grep -q 'pi-package/skills/atlassian/scripts' skills/atlassian/pi/SKILL.md
grep -q 'pi-package/skills/web-automation/scripts' skills/web-automation/pi/SKILL.md
# shellcheck disable=SC2016
grep -q 'local checkout package install keeps the runtime in `pi-package/skills/<skill>/scripts`' docs/PI.md
grep -q 'install-pi-package.sh --global' docs/PI.md
grep -q 'install-pi-package.sh --local' docs/PI.md
grep -q 'install-pi-package.sh --global' README.md
grep -q 'install-pi-package.sh --local' README.md
for family in atlassian create-plan do-task implement-plan web-automation; do
source_dir="skills/${family}/pi"
source_skill_md="${source_dir}/SKILL.md"
skill_name=$(awk '/^name:/ { print $2; exit }' "$source_skill_md")
mirror_dir="pi-package/skills/${skill_name}"
mirror_skill_md="${mirror_dir}/SKILL.md"
test -d "$mirror_dir"
test -f "$mirror_skill_md"
test "$skill_name" = "$(basename "$mirror_dir")"
test "$skill_name" = "$(awk '/^name:/ { print $2; exit }' "$mirror_skill_md")"
diff -qr \
--exclude '.DS_Store' \
--exclude 'node_modules' \
--exclude '.generated-manifest.json' \
--exclude 'package.json' \
"$source_dir" "$mirror_dir" >/dev/null
while IFS= read -r -d '' source_path; do
rel_path=${source_path#"$source_dir"/}
mirror_path="${mirror_dir}/${rel_path}"
test -e "$mirror_path"
test "$(portable_stat_perms "$source_path")" = "$(portable_stat_perms "$mirror_path")"
done < <(find "$source_dir" \
\( -name '.DS_Store' -o -name 'node_modules' \) -prune -o \
-mindepth 1 -print0)
done
# SC2251: restructured to avoid ! outside condition
if grep -nE 'update_plan|plan mode|sub-agent|subagents' \
skills/create-plan/pi/SKILL.md \
skills/do-task/pi/SKILL.md \
skills/implement-plan/pi/SKILL.md; then
echo "Error: pi SKILL.md files must not contain Codex-specific terms" >&2
exit 1
fi
node <<'EOF'
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
if (!pkg.pi || !Array.isArray(pkg.pi.skills) || pkg.pi.skills.length !== 5) {
console.error("package.json must define pi.skills with exactly 5 entries");
process.exit(1);
}
const expectedSkills = [
"./pi-package/skills/atlassian",
"./pi-package/skills/create-plan",
"./pi-package/skills/do-task",
"./pi-package/skills/implement-plan",
"./pi-package/skills/web-automation",
];
if (JSON.stringify(pkg.pi.skills) !== JSON.stringify(expectedSkills)) {
console.error("package.json must point pi.skills at the pi-package mirror");
process.exit(1);
}
if (!Array.isArray(pkg.files) || pkg.files.length === 0) {
console.error("package.json must define a non-empty files allowlist");
process.exit(1);
}
for (const requiredFile of [
"pi-package/skills",
"docs/PI-COMMON-REVIEWER.md",
"scripts/install-pi-package.sh",
"scripts/generate-skills.mjs",
]) {
if (!pkg.files.includes(requiredFile)) {
console.error(`package.json files must include ${requiredFile}`);
process.exit(1);
}
}
for (const forbiddenFile of [
"skills/atlassian/pi",
"skills/create-plan/pi",
"skills/do-task/pi",
"skills/implement-plan/pi",
"skills/web-automation/pi",
]) {
if (pkg.files.includes(forbiddenFile)) {
console.error(`package.json files must not include ${forbiddenFile}`);
process.exit(1);
}
}
if (!Array.isArray(pkg.keywords) || !pkg.keywords.includes("pi-package")) {
console.error("package.json must include the pi-package keyword");
process.exit(1);
}
console.log("package metadata ok");
EOF
echo "pi resources verified"

Some files were not shown because too many files have changed in this diff Show More