From ee84478585fee17130cb5279c28cebcb28760c93 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Fri, 28 Aug 2026 09:01:27 +0200 Subject: [PATCH 1/2] feat: add the check-forge-version skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users have no way to learn that their installed plain-forge is behind the published one, so they keep following skills and rules from a superseded release without knowing it. check-forge-version reports whether the plain-forge on this machine is current. It enumerates every directory plain-forge can install into (both scopes, all agent layouts), reads the installed version from each .plain-forge/manifest.json, resolves the latest version from npm, and emits a PASS / WARN / FAIL verdict. For a stale install it surfaces the exact `npx plain-forge update` command and asks the user to run it — the skill never runs the update itself and modifies nothing on disk. The subject is the tooling, not any project: the skill reads no .plain files, needs no project in the working directory, and does not invoke load-plain-reference. The deterministic work lives in scripts/check-forge-version.mjs, which 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 dotted-numeric version comparison. The verdict is both the first line of stdout and the exit code, so callers need not parse the body. It falls back from the registry endpoint to `npm view` for hosts on an internal mirror. The script locates the install it is executing from by walking up its own path, instead of trusting cwd. Run from its own directory — the natural reading of `node scripts/check-forge-version.mjs` — it would otherwise scan the skill folder as a project root and report NONE from inside a live install, which is the worst possible failure mode for this check. That walk skips the source repo, whose forge/ dir also holds a skills/ subtree and would otherwise be reported as an unmanaged install. load-plain-reference now opens with a call to action to run the check on its first invocation in a session — it is the near-universal entry point for spec work, and a stale forge ships the stale rules it routes to. The check informs the user; it does not block authoring. --- forge/skills/check-forge-version/SKILL.md | 143 ++++++++ .../scripts/check-forge-version.mjs | 323 ++++++++++++++++++ forge/skills/load-plain-reference/SKILL.md | 7 + 3 files changed, 473 insertions(+) create mode 100644 forge/skills/check-forge-version/SKILL.md create mode 100644 forge/skills/check-forge-version/scripts/check-forge-version.mjs diff --git a/forge/skills/check-forge-version/SKILL.md b/forge/skills/check-forge-version/SKILL.md new file mode 100644 index 0000000..3f3471c --- /dev/null +++ b/forge/skills/check-forge-version/SKILL.md @@ -0,0 +1,143 @@ +--- +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`** — name each affected install by agent, scope, and path; give the installed + version and the latest version; then **prompt the user to run**: + + ``` + npx plain-forge update + ``` + + Say that `update` auto-detects every install across both scopes, so a single run covers all of + them, and that it prunes files that no longer ship while never touching the user's own or + third-party files. Then **stop and wait** — do not run it. +- **`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..940308c --- /dev/null +++ b/forge/skills/check-forge-version/scripts/check-forge-version.mjs @@ -0,0 +1,323 @@ +#!/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) +} + +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."); + } else { + lines.push(`Run this to bring ${stale.length + unclear.length} install(s) current:`); + lines.push(""); + lines.push(" npx plain-forge update"); + lines.push(""); + lines.push( + "update auto-detects every install across both scopes, so one run covers all of them.", + ); + } + 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..c6ef056 100644 --- a/forge/skills/load-plain-reference/SKILL.md +++ b/forge/skills/load-plain-reference/SKILL.md @@ -12,6 +12,13 @@ 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 install, +relay its `npx plain-forge update` prompt to the user, then continue with the rules on disk — the +check informs the user, it does not block authoring. 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 From fa6596f9aec12e32514193de8f89ff98be2bbdc4 Mon Sep 17 00:00:00 2001 From: zanjonke Date: Fri, 28 Aug 2026 09:21:01 +0200 Subject: [PATCH 2/2] feat: make the stale-version report insistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut buried the recommendation. A stale install was reported as a neutral aside ("Run this to bring 1 install(s) current"), which reads as optional housekeeping — so a user can see the report and reasonably carry on. Running an outdated plain-forge is not something to shrug at: the skills and rules are the product, and once superseded, specs authored against them may not match what the current codeplain renderer expects. The report now says so. - The script leads the non-PASS case with "ACTION REQUIRED — please update plain-forge before continuing", states that continuing is not recommended and why, and tells the user the fix is one command that cannot touch their own content. An unconfirmed version is called out as one to treat as outdated until proven otherwise. - SKILL.md tells the agent to lead with the recommendation rather than report it in passing, to ask the user directly to update before continuing, and — if the user declines — to name the stale install as the first suspect for anything surprising that follows. It still never runs the update. - load-plain-reference carries the same message at the point it matters, since it is the gate in front of the rules a stale install would serve. Report prose is now wrapped at render time rather than at hardcoded string boundaries, so it no longer breaks mid-sentence, and counts are pluralized. PASS output is unchanged and stays a single quiet line. --- forge/skills/check-forge-version/SKILL.md | 21 +++++-- .../scripts/check-forge-version.mjs | 57 +++++++++++++++++-- forge/skills/load-plain-reference/SKILL.md | 13 +++-- 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/forge/skills/check-forge-version/SKILL.md b/forge/skills/check-forge-version/SKILL.md index 3f3471c..146beab 100644 --- a/forge/skills/check-forge-version/SKILL.md +++ b/forge/skills/check-forge-version/SKILL.md @@ -95,16 +95,27 @@ is one found in the current working directory, and `global` is one under the hom 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`** — name each affected install by agent, scope, and path; give the installed - version and the latest version; then **prompt the user to run**: +- **`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 ``` - Say that `update` auto-detects every install across both scopes, so a single run covers all of - them, and that it prunes files that no longer ship while never touching the user's own or - third-party files. Then **stop and wait** — do not run it. + 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 diff --git a/forge/skills/check-forge-version/scripts/check-forge-version.mjs b/forge/skills/check-forge-version/scripts/check-forge-version.mjs index 940308c..5a1a1ee 100644 --- a/forge/skills/check-forge-version/scripts/check-forge-version.mjs +++ b/forge/skills/check-forge-version/scripts/check-forge-version.mjs @@ -205,6 +205,23 @@ function classify(install, latest) { 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"); @@ -236,15 +253,43 @@ function report(installs, latest, statuses) { lines.push(""); if (verdict === "PASS") { lines.push("Every install is at the latest published version. Nothing to do."); - } else { - lines.push(`Run this to bring ${stale.length + unclear.length} install(s) current:`); - lines.push(""); - lines.push(" npx plain-forge update"); - lines.push(""); + 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( - "update auto-detects every install across both scopes, so one run covers all of them.", + ...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 }; } diff --git a/forge/skills/load-plain-reference/SKILL.md b/forge/skills/load-plain-reference/SKILL.md index c6ef056..3f50fbb 100644 --- a/forge/skills/load-plain-reference/SKILL.md +++ b/forge/skills/load-plain-reference/SKILL.md @@ -14,10 +14,15 @@ the source of truth for `.plain` syntax and authoring constraints. Do not restat **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 install, -relay its `npx plain-forge update` prompt to the user, then continue with the rules on disk — the -check informs the user, it does not block authoring. Skip the check on later invocations in the same -session, and skip it when the network is unavailable. +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