diff --git a/forge/skills/check-forge-version/SKILL.md b/forge/skills/check-forge-version/SKILL.md new file mode 100644 index 0000000..146beab --- /dev/null +++ b/forge/skills/check-forge-version/SKILL.md @@ -0,0 +1,154 @@ +--- +name: check-forge-version +description: >- + Reports whether the plain-forge installation on this machine is current. Enumerates every agent + directory plain-forge can install into — project scope and global scope, across all supported + agent layouts — reads the installed version from each `.plain-forge/manifest.json`, resolves the + latest published version from the npm registry, and emits a PASS / WARN / FAIL verdict naming + every install that is current, stale, or unmanaged. For a stale install it surfaces the exact + `npx plain-forge update` command and asks the user to run it. This concerns the plain-forge + tooling itself, so it is not tied to any ***plain project or authoring workflow. Use when someone + asks whether plain-forge is up to date, on the first use of a forge skill in a session, after a + forge skill or rule behaves unexpectedly, or when a rule referenced in conversation appears to be + missing. Do not use to perform the update, which stays the user's decision. Do not use to check a + project's host tooling, which is what `check-plain-env` covers. +--- + +# Check Forge Version + +This skill answers one question: **is the plain-forge installed on this machine the latest published +version?** It reads the installed version from every install's manifest, compares each against the +`latest` dist-tag on npm, and — when an install is behind — asks the user to run +`npx plain-forge update`. + +The subject is **plain-forge itself**, not any project it is used on. This skill reads no `.plain` +files, needs no ***plain project in the working directory, and is equally valid in an empty +directory. Do not invoke `load-plain-reference` for it; no authoring rules apply. + +## When to run + +- **On the first use of any forge skill in a session** — the cheapest moment to discover that the + skills and rules about to be followed are from a superseded release. +- **On demand** — whenever the user asks whether plain-forge is up to date, which version is + installed, or where it is installed. +- **After a forge skill or rule behaves unexpectedly** — a skill that contradicts the documented + behavior, or a rule file that is missing entirely, is most often a stale install rather than a bug. +- **When a skill or rule named in conversation does not exist on disk** — a newer release probably + added it. +- **After a plain-forge release is announced**, to confirm the update actually landed in every scope. + +## What this skill does NOT do + +- It does **not** run `npx plain-forge update`, or install, or uninstall anything. It reports and + asks; acting is the user's decision. +- It does **not** modify any file — not a manifest, not a skill, not a rule. It is read-only apart + from the network read against the npm registry. +- It does **not** check project host tooling, runtimes, services, or credentials. That is + `check-plain-env`. +- It does **not** validate `.plain` specs. That is `plain-healthcheck`. +- It does **not** resolve which agent the user *should* install into. It reports what is present. + +## Workflow + +### Step 1 — Run the check + +Run the bundled script with the `terminal` tool, passing its path relative to this `SKILL.md`: + +``` +node scripts/check-forge-version.mjs +``` + +**Run it from the project root** whose project-scope install should be checked, giving the full path +to the script — for a Claude layout, `node .claude/skills/check-forge-version/scripts/check-forge-version.mjs`. +The script always reports the install it lives in and the global installs regardless of where it is +invoked, but it can only find a *project-scope* install in the current working directory. When the +report is missing an install that is known to exist, re-run it from that project's root before +concluding anything. + +Add `--json` for a machine-readable report, or `--offline` to list the installs without querying npm. + +The script is the single source of truth for this check. It mirrors `bin/cli.mjs` for both the +install-path model (including the two global paths that break the usual pattern — ForgeCode's +`~/forge` and OpenCode's `~/.config/opencode`) and the version comparison. Do not re-derive either by +hand, and do not substitute an ad-hoc `npm view` plus a guess at where the install lives. + +### Step 2 — Read the verdict + +The verdict is the **first line of stdout**, and it is also the exit code: + +| Exit | Verdict | Meaning | +|---|---|---| +| 0 | `PASS` | Every install found is at the latest version. | +| 1 | `FAIL` | At least one install is behind the latest version. | +| 2 | `WARN` | Installs found, but at least one version is indeterminate (no manifest, or a prerelease). | +| 3 | `ERROR` | The latest version could not be resolved from npm. | +| 4 | `NONE` | No install found in any candidate directory. | + +Per-install statuses are `CURRENT`, `STALE`, `UNMANAGED` (a pre-manifest install — `update` adopts +it and writes a manifest going forward), or `INDETERMINATE`. + +Each install is labelled with its scope: `self` is the install this skill is running from, `project` +is one found in the current working directory, and `global` is one under the home directory. + +### Step 3 — Report and prompt + +Relay the verdict to the user, then act on it: + +- **`PASS`** — state the installed version and that it is current. Stop. Do not suggest an update. +- **`FAIL`** or **`WARN`** — do not report this as a passing aside. Lead with the recommendation, + name each affected install by agent, scope, and path, give the installed version and the latest + version, and **ask the user directly to update before continuing**: + + ``` + npx plain-forge update + ``` + + State plainly that **continuing on an outdated plain-forge is not recommended** — its skills and + rules have been superseded, specs authored against them may not match what the current `codeplain` + renderer expects, and fixes released since the installed version are not in effect. On `WARN`, + where the version could not be confirmed, say the install should be treated as outdated until + proven otherwise. + + Add that a single `update` run covers every install across both scopes, prunes files that no longer + ship, and never touches the user's own or third-party skills — so the fix is one command and + carries no risk to their content. + + Then **stop and wait for the user** — do not run the update. If the user chooses to continue + without updating, proceed with the task, but say once that the stale rules are a likely source of + any surprising behaviour that follows. +- **`NONE`** — report that plain-forge is not installed here and surface `npx plain-forge install`, + noting that the script's output lists every directory that was checked. +- **`ERROR`** — report that the installed versions could not be compared against anything, give the + reason from the script, and offer the two options: re-run when the network is reachable, or check + https://www.npmjs.com/package/plain-forge directly. Never guess whether the install is current. + +When the user agrees to update, they run the command themselves. After it completes, re-run Step 1 to +confirm the result rather than assuming it worked. + +## Error Handling + +- **Registry unreachable** — the script already falls back from the registry endpoint to + `npm view plain-forge version`, which succeeds on hosts pointed at an internal mirror. If both + fail it exits 3; treat the version as unknown and surface the reason instead of assuming staleness. +- **`node` unavailable** — plain-forge requires Node ≥ 18, so this is itself the finding. Report that + Node is missing and that no plain-forge command, including `update`, can run without it. +- **Malformed or hand-edited manifest** — the script treats an unparseable manifest as absent and + falls back to detecting the install by its skill footprint, reporting it as `UNMANAGED`. Recommend + `npx plain-forge update`, which rewrites a correct manifest. +- **An install ahead of the registry** — a local development build reports `CURRENT`, not stale. Do + not prompt for an update in that case. +- **Prerelease installed** — a version such as `1.0.21-rc.1` is not dotted-numeric and reports + `INDETERMINATE`. Prereleases ship under npm's `next` tag, so a comparison against `latest` is not + meaningful; say so rather than prompting for an update that would move the user off the prerelease. + +## Validation Checklist + +- [ ] `scripts/check-forge-version.mjs` was actually executed; no verdict was inferred from memory or + from a bare `npm view`. +- [ ] The verdict reported to the user matches the script's first line and exit code. +- [ ] Every install the script listed was relayed with its agent, scope, and path. +- [ ] `npx plain-forge update` was **offered**, never executed by this skill. +- [ ] Nothing on disk was modified. +- [ ] No `.plain` file was read and `load-plain-reference` was not invoked — this skill is about the + tooling, not about any project. +- [ ] On `ERROR` or `NONE`, no claim was made about whether the install is current. diff --git a/forge/skills/check-forge-version/scripts/check-forge-version.mjs b/forge/skills/check-forge-version/scripts/check-forge-version.mjs new file mode 100644 index 0000000..5a1a1ee --- /dev/null +++ b/forge/skills/check-forge-version/scripts/check-forge-version.mjs @@ -0,0 +1,368 @@ +#!/usr/bin/env node +// +// Report whether the plain-forge installed on this machine is the latest +// published version. +// +// node check-forge-version.mjs # human-readable report +// node check-forge-version.mjs --json # machine-readable report +// node check-forge-version.mjs --offline # skip the registry, list installs only +// +// Exit codes (the verdict is also the first line of stdout): +// 0 PASS — every install found is at the latest version +// 1 FAIL — at least one install is behind the latest version +// 2 WARN — installs found, but at least one version is indeterminate +// 3 ERROR — the latest version could not be resolved from npm +// 4 NONE — no plain-forge install found in any candidate directory +// +// Zero dependencies, Node >= 18 (global fetch). The install-path model and the +// version comparison mirror bin/cli.mjs; keep them in sync with that file. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +const PKG = "plain-forge"; +const REGISTRY = `https://registry.npmjs.org/${PKG}/latest`; +const MANIFEST_REL = path.join(".plain-forge", "manifest.json"); + +// Flagship skills every install ships. All four present means plain-forge is +// installed even when no manifest is (a legacy, pre-manifest install). +const SIGNATURE_SKILLS = [ + "forge-plain", + "add-feature", + "debug-specs", + "load-plain-reference", +]; + +// The install this script is executing from, found by walking up its own path +// rather than trusting cwd: +// /skills/check-forge-version/scripts/check-forge-version.mjs +// Without this, running the script from its own directory (the natural reading +// of "node scripts/check-forge-version.mjs") would scan the skill folder as if +// it were a project root and report NONE while sitting inside a live install. +// Returns null when running from the source repo, where forge/ is not an +// install. +function selfInstall() { + const here = path.dirname(fileURLToPath(import.meta.url)); + if (path.basename(path.resolve(here, "..", "..")) !== "skills") return null; + const dir = path.resolve(here, "..", "..", ".."); + const byDir = { + ".claude": "claude", + ".agents": "codex/copilot/universal", + ".forge": "forgecode", + forge: "forgecode", + ".opencode": "opencode", + opencode: "opencode", + }; + const agent = byDir[path.basename(dir)]; + if (!agent) return null; + // The source repo's forge/ dir also holds skills/, and ForgeCode's global + // install is ~/forge — same basename. Only the source tree sits next to the + // package's own package.json, so that is what separates them. Without this + // guard, running from a checkout reports the repo as an unmanaged install. + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(dir, "..", "package.json"), "utf8"), + ); + if (pkg?.name === PKG) return null; + } catch { + /* no sibling package.json -> a real install, not the source tree */ + } + return { agent, scope: "self", dir }; +} + +// Every directory plain-forge can install into, mirroring resolveBaseDir in +// bin/cli.mjs — including the two global paths that break the usual pattern +// (ForgeCode uses ~/forge, OpenCode uses ~/.config/opencode). The install this +// script lives in comes first so it survives realpath deduplication. +function candidateInstalls() { + const cwd = process.cwd(); + const home = os.homedir(); + const self = selfInstall(); + return [ + ...(self ? [self] : []), + { agent: "claude", scope: "project", dir: path.join(cwd, ".claude") }, + { agent: "codex/copilot/universal", scope: "project", dir: path.join(cwd, ".agents") }, + { agent: "forgecode", scope: "project", dir: path.join(cwd, ".forge") }, + { agent: "opencode", scope: "project", dir: path.join(cwd, ".opencode") }, + { agent: "claude", scope: "global", dir: path.join(home, ".claude") }, + { agent: "codex/copilot/universal", scope: "global", dir: path.join(home, ".agents") }, + { agent: "forgecode", scope: "global", dir: path.join(home, "forge") }, + { agent: "opencode", scope: "global", dir: path.join(home, ".config", "opencode") }, + ]; +} + +function readManifest(baseDir) { + try { + const data = JSON.parse( + fs.readFileSync(path.join(baseDir, MANIFEST_REL), "utf8"), + ); + if (data && Array.isArray(data.files)) return data; + } catch { + /* missing or malformed manifest -> treat as absent */ + } + return null; +} + +function hasForgeSignature(baseDir) { + return SIGNATURE_SKILLS.every((skill) => + fs.existsSync(path.join(baseDir, "skills", skill)), + ); +} + +// Same semantics as compareVersions in bin/cli.mjs: 1 if a > b, -1 if a < b, +// 0 if equal, null when either side is not purely dotted-numeric (a +// prerelease such as "1.0.21-rc.1", or "unknown"). +function compareVersions(a, b) { + const parse = (v) => String(v).split(".").map(Number); + const pa = parse(a); + const pb = parse(b); + if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null; + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const x = pa[i] ?? 0; + const y = pb[i] ?? 0; + if (x > y) return 1; + if (x < y) return -1; + } + return 0; +} + +// Resolve the `latest` dist-tag. Prereleases are published under `next`, so +// `latest` is the right tag to compare a normal install against. +async function resolveLatest() { + try { + const res = await fetch(REGISTRY, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`registry returned HTTP ${res.status}`); + const version = (await res.json())?.version; + if (!version) throw new Error("registry response had no version field"); + return { version, source: "registry" }; + } catch (registryErr) { + // Offline, proxied, or air-gapped hosts often still have a working npm + // configured against an internal mirror. + try { + const out = execFileSync("npm", ["view", PKG, "version"], { + encoding: "utf8", + timeout: 20000, + stdio: ["ignore", "pipe", "ignore"], + }); + const version = out.trim(); + if (!version) throw new Error("npm view printed nothing"); + return { version, source: "npm view" }; + } catch (npmErr) { + return { + version: null, + source: null, + error: `${registryErr.message}; npm fallback: ${npmErr.message}`, + }; + } + } +} + +function findInstalls() { + const installs = []; + const seen = new Set(); + for (const cand of candidateInstalls()) { + if (!fs.existsSync(cand.dir)) continue; + const key = fs.realpathSync(cand.dir); + if (seen.has(key)) continue; // .agents is shared by three agent labels + const manifest = readManifest(cand.dir); + if (manifest) { + seen.add(key); + installs.push({ + ...cand, + // The manifest records which agent layout produced the install; prefer + // it over the label the candidate list guessed. + agent: manifest.agent ?? cand.agent, + installed: manifest.version ?? null, + managed: true, + files: manifest.files.length, + }); + } else if (hasForgeSignature(cand.dir)) { + seen.add(key); + installs.push({ + ...cand, + installed: null, + managed: false, + files: null, + }); + } + } + return installs; +} + +function classify(install, latest) { + if (!install.managed) return "UNMANAGED"; + if (!install.installed) return "INDETERMINATE"; + const cmp = compareVersions(latest, install.installed); + if (cmp === null) return "INDETERMINATE"; + if (cmp > 0) return "STALE"; + return "CURRENT"; // equal, or ahead of the registry (a local dev build) +} + +// Greedy wrap so the report reads as prose in a terminal instead of breaking +// mid-sentence at hardcoded string boundaries. +function wrap(text, width = 84) { + const out = []; + let line = ""; + for (const word of text.split(/\s+/)) { + if (line && line.length + 1 + word.length > width) { + out.push(line); + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + if (line) out.push(line); + return out; +} + +function report(installs, latest, statuses) { + const lines = []; + const stale = statuses.filter((s) => s.status === "STALE"); + const unclear = statuses.filter( + (s) => s.status === "UNMANAGED" || s.status === "INDETERMINATE", + ); + + let verdict; + if (stale.length > 0) verdict = "FAIL"; + else if (unclear.length > 0) verdict = "WARN"; + else verdict = "PASS"; + + lines.push( + `${verdict} — latest ${PKG} on npm is v${latest.version} (via ${latest.source})`, + ); + lines.push(""); + lines.push(`Found ${installs.length} install(s):`); + for (const s of statuses) { + const where = `${s.install.agent} (${s.install.scope})`; + const version = s.install.installed ? `v${s.install.installed}` : "version unknown"; + lines.push(` [${s.status}] ${where} — ${version}`); + lines.push(` ${s.install.dir}`); + if (s.status === "UNMANAGED") { + lines.push( + " no .plain-forge/manifest.json — a pre-manifest install; update adopts it", + ); + } + } + lines.push(""); + if (verdict === "PASS") { + lines.push("Every install is at the latest published version. Nothing to do."); + return { text: lines.join("\n"), verdict }; + } + + lines.push("ACTION REQUIRED — please update plain-forge before continuing."); + lines.push(""); + if (stale.length > 0) { + const n = stale.length; + lines.push( + ...wrap( + `${n} ${n === 1 ? "install is" : "installs are"} behind v${latest.version}. ` + + "Continuing on an outdated plain-forge is not recommended: its skills and rules have " + + "been superseded, so specs authored against them may not match what the current " + + "codeplain renderer expects, and fixes released since this version are not in effect.", + ), + ); + } + if (unclear.length > 0) { + const n = unclear.length; + if (stale.length > 0) lines.push(""); + lines.push( + ...wrap( + `${n} ${n === 1 ? "install could" : "installs could"} not be confirmed as current. ` + + "Treat it as outdated until proven otherwise — updating is the cheapest way to " + + "remove the doubt.", + ), + ); + } + lines.push(""); + lines.push(" npx plain-forge update"); + lines.push(""); + lines.push( + ...wrap( + "One run covers everything: update auto-detects every install across both scopes, prunes " + + "files that no longer ship, and never touches your own or third-party skills. Re-run " + + "this check afterwards to confirm.", + ), + ); + return { text: lines.join("\n"), verdict }; +} + +async function main() { + const argv = process.argv.slice(2); + const asJson = argv.includes("--json"); + const offline = argv.includes("--offline"); + + const installs = findInstalls(); + + if (installs.length === 0) { + const msg = + `NONE — no ${PKG} install found.\n\n` + + "Checked these directories (none held a .plain-forge/manifest.json or a\n" + + "recognizable forge skill tree):\n" + + candidateInstalls().map((c) => ` ${c.dir}`).join("\n") + + `\n\nInstall it with:\n\n npx ${PKG} install\n`; + console.log(asJson ? JSON.stringify({ verdict: "NONE", installs: [] }, null, 2) : msg); + process.exit(4); + } + + if (offline) { + const out = installs + .map( + (i) => + ` ${i.agent} (${i.scope}) — ${i.installed ? `v${i.installed}` : "version unknown"}\n ${i.dir}`, + ) + .join("\n"); + console.log( + asJson + ? JSON.stringify({ verdict: "OFFLINE", installs }, null, 2) + : `OFFLINE — registry not queried.\n\nFound ${installs.length} install(s):\n${out}\n`, + ); + process.exit(0); + } + + const latest = await resolveLatest(); + if (!latest.version) { + const out = installs + .map( + (i) => + ` ${i.agent} (${i.scope}) — ${i.installed ? `v${i.installed}` : "version unknown"}`, + ) + .join("\n"); + const msg = + `ERROR — could not resolve the latest ${PKG} version from npm.\n\n` + + `Reason: ${latest.error}\n\n` + + `Installed versions found (not compared against anything):\n${out}\n\n` + + "Re-run when the network is available, or check https://www.npmjs.com/package/" + + `${PKG} manually.\n`; + console.error( + asJson + ? JSON.stringify({ verdict: "ERROR", error: latest.error, installs }, null, 2) + : msg, + ); + process.exit(3); + } + + const statuses = installs.map((install) => ({ + install, + status: classify(install, latest.version), + })); + + const { text, verdict } = report(installs, latest, statuses); + console.log( + asJson + ? JSON.stringify({ verdict, latest: latest.version, source: latest.source, installs: statuses }, null, 2) + : text, + ); + process.exit(verdict === "FAIL" ? 1 : verdict === "WARN" ? 2 : 0); +} + +main().catch((err) => { + console.error(`ERROR — check-forge-version failed unexpectedly: ${err.message}`); + process.exit(3); +}); diff --git a/forge/skills/load-plain-reference/SKILL.md b/forge/skills/load-plain-reference/SKILL.md index fb4d2d9..3f50fbb 100644 --- a/forge/skills/load-plain-reference/SKILL.md +++ b/forge/skills/load-plain-reference/SKILL.md @@ -12,6 +12,18 @@ description: >- Load only the rules and references required for the current task. Files under `../../rules/` are the source of truth for `.plain` syntax and authoring constraints. Do not restate or override them. +**First, confirm the rules about to be loaded are current.** On the first invocation of this skill in +a session, invoke `check-forge-version` before loading any rule file. A stale plain-forge ships stale +rules, so every routing decision below inherits its verdict. + +If that skill reports a stale or unconfirmed install, tell the user clearly that **authoring on an +outdated plain-forge is not recommended** and ask them to run `npx plain-forge update` before +continuing — the rules routed to below may not match what the current `codeplain` renderer expects. +Give them the choice rather than blocking: if they decline, continue with the rules on disk and treat +the stale install as the first suspect for anything that later behaves unexpectedly. + +Skip the check on later invocations in the same session, and skip it when the network is unavailable. + ## 1. Account for rules already loaded - In Claude Code, applicable `.claude/rules/*.md` files are loaded natively. Do not read those rule