From f91be407f4a196cd3bf8c90bc8389c2455906656 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Sun, 25 Oct 2026 15:37:24 +0000 Subject: [PATCH 1/8] feat(framework): show the locally resolved framework version in the startup greeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup greeting now carries the framework version resolved locally, once per new conversation: AI-Driven Development On ⚡ · Framework v · [Releases](https://github.com/ai-driven-dev/framework/releases) is read from the "version" field of .claude-plugin/marketplace.json at the repository root — the framework release version, kept equal to the "." entry of .release-please-manifest.json by release-please. When it cannot be established, the greeting displays "Framework version unknown". The CLI version, a plugin version, and the root package.json 0.0.0 placeholder are never substituted. - AGENTS.md, CLAUDE.md and cli/CLAUDE.md carried three drifted greetings; they now share one canonical instruction (drift tracked by #619). - Version-source decision recorded in aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md Verified in new OpenCode sessions: in this worktree the greeting reads "Framework v5.12.0" after a local read of .claude-plugin/marketplace.json; in a project without that file it reads "Framework version unknown". aidd-dev check passes (527 test files, 6866 tests). Implements the visible behavior of #980. The update-notification half (CLI surface, plugin-owned startup check, cached remote comparison, install provenance) stays open. --- AGENTS.md | 3 +- CLAUDE.md | 3 +- ...ting-resolves-framework-version-locally.md | 50 +++++++++++++++++++ cli/CLAUDE.md | 2 +- 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md diff --git a/AGENTS.md b/AGENTS.md index 53ad41f8b..4ccfdc26e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -> On 1st message, greet user with: "AI-Driven Development On ⚡" +> On 1st message of a conversation, greet the user once with: "AI-Driven Development On ⚡ · Framework v · [Releases](https://github.com/ai-driven-dev/framework/releases)". Resolve locally before greeting: read the "version" field of .claude-plugin/marketplace.json at the repository root (the framework release version, never the CLI version or a plugin version). If it cannot be established, display "Framework version unknown" in place of "Framework v". ## Behavior @@ -54,6 +54,7 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md +- aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md diff --git a/CLAUDE.md b/CLAUDE.md index 410b0253c..9aa98d1f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -> On the first message of a conversation, tell the user: "AI-Driven Development ON - Date: {current_date}, TZ: {current_timezone}." +> On 1st message of a conversation, greet the user once with: "AI-Driven Development On ⚡ · Framework v · [Releases](https://github.com/ai-driven-dev/framework/releases)". Resolve locally before greeting: read the "version" field of .claude-plugin/marketplace.json at the repository root (the framework release version, never the CLI version or a plugin version). If it cannot be established, display "Framework version unknown" in place of "Framework v". ## Behavior @@ -54,6 +54,7 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md +- aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md diff --git a/aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md b/aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md new file mode 100644 index 000000000..f3f25a48f --- /dev/null +++ b/aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md @@ -0,0 +1,50 @@ +# The startup greeting resolves the framework version locally, and unknown is shown rather than substituted + +- Date: 2026-10-25 +- Status: Accepted — implements the visible behavior of #980 +- Decided by: proposed here, for @alexsoyes to confirm or overturn + +## Context + +#980 asks the startup greeting to carry the framework version. Three agent files shipped +three drifted greetings (`AGENTS.md`, `CLAUDE.md`, `cli/CLAUDE.md`), and the candidate +version sources disagree in kind: + +- Root `package.json` says `0.0.0`: the root package is private and unpublished, its + version field is a placeholder, not a release. +- `.release-please-manifest.json` holds the framework release under the `"."` key. +- `.claude-plugin/marketplace.json` carries the same number in its `version` field, and + release-please bumps it as an extra-file of the root package on every framework release. +- `cli/package.json` is the CLI version and `plugins/*/.claude-plugin/plugin.json` are + plugin versions; #980 forbids substituting either for the framework version. + +## Decision + +The greeting resolves `` from the `version` field of `.claude-plugin/marketplace.json` +at the repository root. That file is the marketplace manifest a native install fetches, and +release-please keeps it equal to the `"."` entry of `.release-please-manifest.json`, so the two +agree by construction. If the file cannot be read, the greeting displays +`Framework version unknown`. The CLI version, a plugin version, and the root `package.json` +placeholder are never substituted. + +The greeting itself is unified across `AGENTS.md`, `CLAUDE.md`, and `cli/CLAUDE.md`: + +> AI-Driven Development On ⚡ · Framework v · [Releases](https://github.com/ai-driven-dev/framework/releases) + +## Consequences + +- A framework checkout (this repository, a git tag, a local marketplace directory) resolves + the version on the first message of every new conversation, and after an upgrade the next + conversation resolves the updated version, because release-please bumps the file. +- A user project installed from the marketplace holds no framework file at its root, so it + displays `Framework version unknown` until install-time provenance lands. That is the + fallback #980 explicitly allows, not a silent wrong answer. +- The shipped project template (`aidd-context` `02-project-memory`) intentionally carries no + greeting since 4446fff; this decision does not reinstate one there. +- The three drifted greetings collapse into one, closing the drift #619 tracked. + +## Overturning + +Two facts would justify revisiting: the CLI starts recording the marketplace version in +`.aidd/manifest.json` at install time (the greeting chain gains a second, install-provenance +source), or the project template regains a greeting (the same rule applies there verbatim). diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index 79c9101d3..c2d06ae06 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -1,6 +1,6 @@ # AGENTS.md -> On the first message of a conversation, tell the user: "AI-Driven Development ON - Date: {current_date}, TZ: {current_timezone}." +> On 1st message of a conversation, greet the user once with: "AI-Driven Development On ⚡ · Framework v · [Releases](https://github.com/ai-driven-dev/framework/releases)". Resolve locally before greeting: read the "version" field of .claude-plugin/marketplace.json at the repository root (the framework release version, never the CLI version or a plugin version). If it cannot be established, display "Framework version unknown" in place of "Framework v". ## Behavior From 4ff67ed991b77efe858d17bdcc18bc0d29b1bc0d Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Sun, 25 Oct 2026 17:23:21 +0000 Subject: [PATCH 2/8] feat(framework): stamp the framework version into the startup greeting, bumped by release PRs The greeting in AGENTS.md, CLAUDE.md and cli/CLAUDE.md now carries the literal release version ("Framework v5.12.0"), annotated with x-release-please-version, and the three files join the root package's extra-files in release-please-config.json. Every release PR bumps the stamped version, alongside marketplace.json: the version is dynamic, delivered by the pull request process. This replaces the previous runtime-resolution instruction (the agent read marketplace.json at conversation start): non-deterministic, and "Framework version unknown" in projects installed from the marketplace, where no marketplace.json exists at the project root. The agent now repeats the greeting verbatim. Decision: aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md Verified in new OpenCode sessions in the feature worktree: the greeting shows v5.12.0 verbatim; with only the agent file present (no marketplace.json) it still shows v5.12.0; simulating a release PR (Generic updater logic applied to the annotated lines, 5.12.0 -> 5.13.0) shows v5.13.0. aidd-dev check passes. Implements the visible behavior of #980. The update-notification half (CLI surface, plugin-owned startup check, cached remote comparison, install provenance) stays open. --- AGENTS.md | 4 +- CLAUDE.md | 4 +- ...g-carries-the-framework-release-version.md | 55 +++++++++++++++++++ ...ting-resolves-framework-version-locally.md | 50 ----------------- cli/CLAUDE.md | 2 +- release-please-config.json | 5 +- 6 files changed, 64 insertions(+), 56 deletions(-) create mode 100644 aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md delete mode 100644 aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md diff --git a/AGENTS.md b/AGENTS.md index 4ccfdc26e..dc9de4e1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -> On 1st message of a conversation, greet the user once with: "AI-Driven Development On ⚡ · Framework v · [Releases](https://github.com/ai-driven-dev/framework/releases)". Resolve locally before greeting: read the "version" field of .claude-plugin/marketplace.json at the repository root (the framework release version, never the CLI version or a plugin version). If it cannot be established, display "Framework version unknown" in place of "Framework v". +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" ## Behavior @@ -54,7 +54,7 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md -- aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md +- aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md diff --git a/CLAUDE.md b/CLAUDE.md index 9aa98d1f5..e42e1fd63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -> On 1st message of a conversation, greet the user once with: "AI-Driven Development On ⚡ · Framework v · [Releases](https://github.com/ai-driven-dev/framework/releases)". Resolve locally before greeting: read the "version" field of .claude-plugin/marketplace.json at the repository root (the framework release version, never the CLI version or a plugin version). If it cannot be established, display "Framework version unknown" in place of "Framework v". +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" ## Behavior @@ -54,7 +54,7 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md -- aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md +- aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md diff --git a/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md b/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md new file mode 100644 index 000000000..80ffa9f34 --- /dev/null +++ b/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md @@ -0,0 +1,55 @@ +# The startup greeting carries the framework release version, stamped in the agent files and bumped by the release PR + +- Date: 2026-10-25 +- Status: Accepted — implements the visible behavior of #980 +- Decided by: proposed here, for @alexsoyes to confirm or overturn + +## Context + +#980 asks the startup greeting to carry the framework version, directly from the agent +files. Two mechanisms were on the table: + +1. **Runtime resolution.** The agent reads `.claude-plugin/marketplace.json` at the first + message of each conversation and composes the greeting. Dynamic, but the resolution is + LLM-mediated: non-deterministic (the model may skip the read, hallucinate, or misformat), + and in a project installed from the marketplace there is no `marketplace.json` at the + project root, so it displays "Framework version unknown". For real users, the feature + shows nothing. +2. **Stamping.** The release version is written into the greeting line of the agent files, + and release-please bumps it on every release PR, alongside `.claude-plugin/marketplace.json`. + The agent repeats the greeting verbatim. Deterministic, and the version is literally in + agent.md. + +## Decision + +**Stamp.** The greeting in `AGENTS.md`, `CLAUDE.md`, and `cli/CLAUDE.md` carries the literal +release version, annotated for release-please's Generic updater: + +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" + +The root package in `release-please-config.json` lists the three files as `extra-files`. +On every release PR, release-please's Generic updater replaces the semver on each annotated +line with the new version — the same PR that bumps `marketplace.json` bumps the greeting. +The version is dynamic: it changes with every release PR. Between releases it shows the +last release, which is exactly what `marketplace.json` reports. + +## Consequences + +- Deterministic: no LLM file-reading, no hallucination risk, no per-conversation resolution + tokens. The agent repeats the greeting verbatim. +- The version is literally in agent.md — directly from the agent file, as #980's visible + behavior asks. +- The "Framework version unknown" fallback is unnecessary for the greeting: the stamp is + always present. The fallback stays relevant for the update-notice half, which resolves + the installed version per installation. +- The shipped project template (`aidd-context` `02-project-memory`) intentionally carries no + greeting since 4446fff; unchanged. +- The three drifted greetings stay unified on the canonical instruction. + +## Overturning + +If a project template regains a greeting, stamp it the same way: the template ships per +release, so an installed project's agent file would carry the version of the release it +installed. If the Generic updater ever stops matching the annotated line (a format change), +the stamp goes stale silently — the annotated line is the single point to check at the next +release. diff --git a/aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md b/aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md deleted file mode 100644 index f3f25a48f..000000000 --- a/aidd_docs/memory/internal/decisions/startup-greeting-resolves-framework-version-locally.md +++ /dev/null @@ -1,50 +0,0 @@ -# The startup greeting resolves the framework version locally, and unknown is shown rather than substituted - -- Date: 2026-10-25 -- Status: Accepted — implements the visible behavior of #980 -- Decided by: proposed here, for @alexsoyes to confirm or overturn - -## Context - -#980 asks the startup greeting to carry the framework version. Three agent files shipped -three drifted greetings (`AGENTS.md`, `CLAUDE.md`, `cli/CLAUDE.md`), and the candidate -version sources disagree in kind: - -- Root `package.json` says `0.0.0`: the root package is private and unpublished, its - version field is a placeholder, not a release. -- `.release-please-manifest.json` holds the framework release under the `"."` key. -- `.claude-plugin/marketplace.json` carries the same number in its `version` field, and - release-please bumps it as an extra-file of the root package on every framework release. -- `cli/package.json` is the CLI version and `plugins/*/.claude-plugin/plugin.json` are - plugin versions; #980 forbids substituting either for the framework version. - -## Decision - -The greeting resolves `` from the `version` field of `.claude-plugin/marketplace.json` -at the repository root. That file is the marketplace manifest a native install fetches, and -release-please keeps it equal to the `"."` entry of `.release-please-manifest.json`, so the two -agree by construction. If the file cannot be read, the greeting displays -`Framework version unknown`. The CLI version, a plugin version, and the root `package.json` -placeholder are never substituted. - -The greeting itself is unified across `AGENTS.md`, `CLAUDE.md`, and `cli/CLAUDE.md`: - -> AI-Driven Development On ⚡ · Framework v · [Releases](https://github.com/ai-driven-dev/framework/releases) - -## Consequences - -- A framework checkout (this repository, a git tag, a local marketplace directory) resolves - the version on the first message of every new conversation, and after an upgrade the next - conversation resolves the updated version, because release-please bumps the file. -- A user project installed from the marketplace holds no framework file at its root, so it - displays `Framework version unknown` until install-time provenance lands. That is the - fallback #980 explicitly allows, not a silent wrong answer. -- The shipped project template (`aidd-context` `02-project-memory`) intentionally carries no - greeting since 4446fff; this decision does not reinstate one there. -- The three drifted greetings collapse into one, closing the drift #619 tracked. - -## Overturning - -Two facts would justify revisiting: the CLI starts recording the marketplace version in -`.aidd/manifest.json` at install time (the greeting chain gains a second, install-provenance -source), or the project template regains a greeting (the same rule applies there verbatim). diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index c2d06ae06..f314471b7 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -1,6 +1,6 @@ # AGENTS.md -> On 1st message of a conversation, greet the user once with: "AI-Driven Development On ⚡ · Framework v · [Releases](https://github.com/ai-driven-dev/framework/releases)". Resolve locally before greeting: read the "version" field of .claude-plugin/marketplace.json at the repository root (the framework release version, never the CLI version or a plugin version). If it cannot be established, display "Framework version unknown" in place of "Framework v". +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" ## Behavior diff --git a/release-please-config.json b/release-please-config.json index bd7d523ce..a0eb40b41 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -22,7 +22,10 @@ "type": "json", "path": ".claude-plugin/marketplace.json", "jsonpath": "$.version" - } + }, + "AGENTS.md", + "CLAUDE.md", + "cli/CLAUDE.md" ], "exclude-paths": [".release-please-manifest.json"] }, From 0751d0618fb25a1bc646e41f836acdeee592596b Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Sun, 25 Oct 2026 18:01:00 +0000 Subject: [PATCH 3/8] feat(framework): generate the greeting version with a transformation script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup greeting carries the framework release version, stamped by scripts/sync-greeting-version.mjs: the script reads the release version from .claude-plugin/marketplace.json (as cut on the next branch) and rewrites Framework v on the greeting line of every target — the framework's own agent files (AGENTS.md, CLAUDE.md, cli/CLAUDE.md) and the usage template shipped to user projects (plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md). The usage template gains the greeting (it had none since 4446fff), so a project set up from a release greets with that release's version. A lefthook pre-commit hook (sync-greeting-version, same pattern as sync-readme-counts) runs the script on every commit and re-stages the touched files, so the greeting never drifts from the release. The script is idempotent and supports --check. This replaces the release-please extra-files stamping from the previous commit: the upkeep now belongs to the framework's own transformation script, matching the issue's direction that the framework generates information dynamically with tooling. Decision: aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md Verified: 5 new tests for the script pass; simulating a release (marketplace version 5.12.0 -> 5.13.0) and running the script re-stamps all four targets to v5.13.0; a new OpenCode session in a project built from the template greets with "Framework v5.12.0" verbatim. aidd-dev check passes. --- AGENTS.md | 2 +- CLAUDE.md | 2 +- ...g-carries-the-framework-release-version.md | 76 +++++++------- cli/CLAUDE.md | 2 +- lefthook.yml | 8 ++ .../assets/templates/AGENTS.md | 2 + release-please-config.json | 5 +- .../__tests__/sync-greeting-version.test.js | 98 +++++++++++++++++++ scripts/sync-greeting-version.mjs | 78 +++++++++++++++ 9 files changed, 230 insertions(+), 43 deletions(-) create mode 100644 scripts/__tests__/sync-greeting-version.test.js create mode 100644 scripts/sync-greeting-version.mjs diff --git a/AGENTS.md b/AGENTS.md index dc9de4e1a..8890b4dd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" ## Behavior diff --git a/CLAUDE.md b/CLAUDE.md index e42e1fd63..e8c52ac2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" ## Behavior diff --git a/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md b/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md index 80ffa9f34..d68a4ed9e 100644 --- a/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md +++ b/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md @@ -1,4 +1,4 @@ -# The startup greeting carries the framework release version, stamped in the agent files and bumped by the release PR +# The startup greeting carries the framework release version, synced by a transformation script - Date: 2026-10-25 - Status: Accepted — implements the visible behavior of #980 @@ -6,50 +6,54 @@ ## Context -#980 asks the startup greeting to carry the framework version, directly from the agent -files. Two mechanisms were on the table: +#980 asks the startup greeting to carry the framework version, directly from the +agent files. Three mechanisms were considered: -1. **Runtime resolution.** The agent reads `.claude-plugin/marketplace.json` at the first - message of each conversation and composes the greeting. Dynamic, but the resolution is - LLM-mediated: non-deterministic (the model may skip the read, hallucinate, or misformat), - and in a project installed from the marketplace there is no `marketplace.json` at the - project root, so it displays "Framework version unknown". For real users, the feature - shows nothing. -2. **Stamping.** The release version is written into the greeting line of the agent files, - and release-please bumps it on every release PR, alongside `.claude-plugin/marketplace.json`. - The agent repeats the greeting verbatim. Deterministic, and the version is literally in - agent.md. +1. **Runtime resolution by the agent.** The agent reads `.claude-plugin/marketplace.json` + at the first message of each conversation. Non-deterministic (the model may skip the + read, hallucinate, or misformat), and in a project installed from the marketplace there + is no `marketplace.json` at the project root, so it displays "Framework version unknown". +2. **Release-time stamping by release-please** (`extra-files` with an + `x-release-please-version` annotation). Deterministic, but it ties the greeting's + upkeep to the release tooling's file list. +3. **A transformation script** (`scripts/sync-greeting-version.mjs`) that reads the + framework release version and stamps it into the greeting of every target file. + +The framework's purpose is to generate information dynamically with tooling, not to ask +the model to resolve it or to hardcode it. ## Decision -**Stamp.** The greeting in `AGENTS.md`, `CLAUDE.md`, and `cli/CLAUDE.md` carries the literal -release version, annotated for release-please's Generic updater: +**A transformation script.** `scripts/sync-greeting-version.mjs` reads the framework +release version from `.claude-plugin/marketplace.json` (the version as cut on the next +branch) and rewrites `Framework v` on the greeting line of every target: + +- `AGENTS.md`, `CLAUDE.md`, `cli/CLAUDE.md` — the framework's own agent files; +- `plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md` — the usage + template shipped to user projects, which becomes their AGENTS.md. + +The script is idempotent and supports `--check` (report instead of write). A lefthook +pre-commit hook (`sync-greeting-version`) runs it on every commit and re-stages the +touched files, so the greeting never drifts from the release. -> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" +The greeting is one canonical line everywhere: -The root package in `release-please-config.json` lists the three files as `extra-files`. -On every release PR, release-please's Generic updater replaces the semver on each annotated -line with the new version — the same PR that bumps `marketplace.json` bumps the greeting. -The version is dynamic: it changes with every release PR. Between releases it shows the -last release, which is exactly what `marketplace.json` reports. +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" ## Consequences -- Deterministic: no LLM file-reading, no hallucination risk, no per-conversation resolution - tokens. The agent repeats the greeting verbatim. -- The version is literally in agent.md — directly from the agent file, as #980's visible - behavior asks. -- The "Framework version unknown" fallback is unnecessary for the greeting: the stamp is - always present. The fallback stays relevant for the update-notice half, which resolves - the installed version per installation. -- The shipped project template (`aidd-context` `02-project-memory`) intentionally carries no - greeting since 4446fff; unchanged. -- The three drifted greetings stay unified on the canonical instruction. +- Deterministic: the agent repeats the greeting verbatim; no file-reading, no + hallucination risk, no per-conversation resolution tokens. +- The version is dynamic: the script re-stamps it from the release version, and the + pre-commit hook keeps every target in sync at the next commit after the version changes. +- The version is literally in the agent files, and the usage template carries it too: + a project set up from a release greets with the version of that release. +- The "Framework version unknown" fallback is unnecessary: the stamp is always present. +- The shipped template intentionally had no greeting since 4446fff; #980 reinstates it + with the version, stamped by the script. ## Overturning -If a project template regains a greeting, stamp it the same way: the template ships per -release, so an installed project's agent file would carry the version of the release it -installed. If the Generic updater ever stops matching the annotated line (a format change), -the stamp goes stale silently — the annotated line is the single point to check at the next -release. +If the version source ever moves out of `.claude-plugin/marketplace.json`, the script's +single read point changes with it. If the greeting format changes, the script's line +match is the single point to update. diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index f314471b7..6206b12be 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -1,6 +1,6 @@ # AGENTS.md -> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" ## Behavior diff --git a/lefthook.yml b/lefthook.yml index 6972197ae..003f75594 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -149,6 +149,14 @@ pre-commit: fi node scripts/sync-readme-counts.mjs >/dev/null git add README.md 2>/dev/null || true + sync-greeting-version: + run: | + if ! command -v node >/dev/null 2>&1; then + echo 'ℹ️ node not available; skipping sync-greeting-version' + exit 0 + fi + node scripts/sync-greeting-version.mjs >/dev/null + git add AGENTS.md CLAUDE.md cli/CLAUDE.md plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md 2>/dev/null || true cli-biome: glob: "cli/**" run: cd cli && pnpm lint diff --git a/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md b/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md index d126acad9..217591ff6 100644 --- a/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md +++ b/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md @@ -1,5 +1,7 @@ # AGENTS.md +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" + ## Behavior - **Be anti-sycophantic and neutral:** the user could also be wrong. diff --git a/release-please-config.json b/release-please-config.json index a0eb40b41..bd7d523ce 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -22,10 +22,7 @@ "type": "json", "path": ".claude-plugin/marketplace.json", "jsonpath": "$.version" - }, - "AGENTS.md", - "CLAUDE.md", - "cli/CLAUDE.md" + } ], "exclude-paths": [".release-please-manifest.json"] }, diff --git a/scripts/__tests__/sync-greeting-version.test.js b/scripts/__tests__/sync-greeting-version.test.js new file mode 100644 index 000000000..0b3ec674b --- /dev/null +++ b/scripts/__tests__/sync-greeting-version.test.js @@ -0,0 +1,98 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const SCRIPT = path.resolve(__dirname, "..", "sync-greeting-version.mjs"); + +const TARGETS = [ + "AGENTS.md", + "CLAUDE.md", + "cli/CLAUDE.md", + "plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md", +]; + +const GREETING = (v) => + `# AGENTS.md\n\n> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v${v} · [Releases](https://github.com/ai-driven-dev/framework/releases)"\n`; + +/** A throwaway framework checkout: a marketplace manifest and the four greeting targets. */ +function makeCheckout(version, greetingVersion = "0.0.0") { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sync-greeting-version-")); + fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(root, ".claude-plugin/marketplace.json"), + JSON.stringify({ name: "aidd-framework", version }), + ); + for (const target of TARGETS) { + const file = path.join(root, target); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, GREETING(greetingVersion)); + } + return root; +} + +function run(root, ...flags) { + return spawnSync(process.execPath, [SCRIPT, "--root", root, ...flags], { encoding: "utf8" }); +} + +test("the greeting version check agrees with the release in the repository", () => { + const result = spawnSync(process.execPath, [SCRIPT, "--check"], { encoding: "utf8" }); + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); +}); + +test("the script stamps the release version into every greeting target", () => { + const root = makeCheckout("5.13.0"); + try { + const result = run(root); + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); + for (const target of TARGETS) { + const md = fs.readFileSync(path.join(root, target), "utf8"); + assert.match(md, /Framework v5\.13\.0/u); + assert.doesNotMatch(md, /Framework v0\.0\.0/u); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("the script is idempotent and --check passes once synced", () => { + const root = makeCheckout("5.13.0"); + try { + assert.equal(run(root).status, 0); + const before = TARGETS.map((t) => fs.readFileSync(path.join(root, t), "utf8")); + assert.equal(run(root).status, 0); + const after = TARGETS.map((t) => fs.readFileSync(path.join(root, t), "utf8")); + assert.deepEqual(after, before); + assert.equal(run(root, "--check").status, 0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("--check fails on a stale greeting and the script fixes it", () => { + const root = makeCheckout("5.13.0", "5.12.0"); + try { + const stale = run(root, "--check"); + assert.equal(stale.status, 1, `${stale.stdout}${stale.stderr}`); + assert.match(stale.stderr, /stale/u); + assert.equal(run(root).status, 0); + assert.match(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), /Framework v5\.13\.0/u); + assert.equal(run(root, "--check").status, 0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("a target without a stamped greeting fails loudly", () => { + const root = makeCheckout("5.13.0"); + try { + fs.writeFileSync(path.join(root, "AGENTS.md"), "# AGENTS.md\n\nno greeting here\n"); + const result = run(root); + assert.equal(result.status, 1); + assert.match(result.stderr, /no stamped greeting/u); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/sync-greeting-version.mjs b/scripts/sync-greeting-version.mjs new file mode 100644 index 000000000..b16bf9d29 --- /dev/null +++ b/scripts/sync-greeting-version.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// Keeps the framework release version in the startup greeting in sync with the +// release itself. The greeting lives in the framework's own agent files +// (AGENTS.md, CLAUDE.md, cli/CLAUDE.md) and in the usage template shipped to +// user projects (plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md). +// The version source is .claude-plugin/marketplace.json, the framework release +// version as cut on the next branch. `--check` reports instead of writing. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const args = process.argv.slice(2); +const rootFlag = args.indexOf("--root"); +const ROOT = + rootFlag !== -1 + ? resolve(args[rootFlag + 1]) + : fileURLToPath(new URL("..", import.meta.url)); +const CHECK = args.includes("--check"); + +const MANIFEST = `${ROOT}/.claude-plugin/marketplace.json`; + +const TARGETS = [ + "AGENTS.md", + "CLAUDE.md", + "cli/CLAUDE.md", + "plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md", +]; + +const GREETING_LINE = /greet/iu; +const STAMPED_VERSION = /Framework v\d+\.\d+\.\d+/u; + +const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")); +const version = manifest.version; +if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/u.test(version)) { + console.error(`❌ unexpected framework version in .claude-plugin/marketplace.json: ${String(version)}`); + process.exit(1); +} + +const stamped = `Framework v${version}`; +const touched = []; + +for (const target of TARGETS) { + const file = `${ROOT}/${target}`; + if (!existsSync(file)) { + console.error(`❌ missing greeting target: ${target}`); + process.exit(1); + } + const before = readFileSync(file, "utf8"); + let found = false; + const after = before + .split("\n") + .map((line) => { + if (!GREETING_LINE.test(line) || !STAMPED_VERSION.test(line)) return line; + found = true; + return line.replace(STAMPED_VERSION, stamped); + }) + .join("\n"); + if (!found) { + console.error(`❌ no stamped greeting in ${target}`); + process.exit(1); + } + if (after !== before) { + touched.push(target); + if (!CHECK) writeFileSync(file, after); + } +} + +if (touched.length === 0) { + process.exit(0); +} + +if (CHECK) { + console.error(`❌ greeting version is stale in: ${touched.join(", ")}. Run: node scripts/sync-greeting-version.mjs`); + process.exit(1); +} + +console.log(`greeting version synced to v${version} in ${touched.length} file(s).`); From a71fa4139c8496fad02ea8af9ccd549b1b612179 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Mon, 26 Oct 2026 03:01:54 +0000 Subject: [PATCH 4/8] feat(framework): resolve the installed framework version at session start The greeting no longer carries a version stamped into the agent files: a copied template goes stale at the first upgrade and is shared by every teammate. aidd-context's SessionStart script reads the marketplace version from the installation's own provenance (Claude Code cache, aidd CLI record, local marketplace) and hands it to the conversation, or reports it unknown. A newer release on the same GitHub source and branch is announced from a 24h cache refreshed by a detached child, with the update path of the installation mode; pinned refs, local checkouts and other hosts are never compared and nothing is installed. OpenCode's generated bridge now forwards SessionStart output to every model call (V1 system transform, V2 context hook). The CLI menu gains a CLI version entry over `aidd update --check`. Refs #980 --- AGENTS.md | 4 +- CLAUDE.md | 4 +- aidd_docs/memory/README.md | 1 + ...g-carries-the-framework-release-version.md | 59 ---- ...g-reads-the-installed-framework-version.md | 26 ++ cli/ARCHITECTURE.md | 2 +- cli/CLAUDE.md | 2 +- .../configs/opencode/opencode-events.js.txt | 21 ++ .../opencode/opencode-hooks-bridge.ts | 74 ++++- cli/src/presentation/prompts/menu-use-case.ts | 8 +- ...bridge-session-context.integration.test.ts | 154 +++++++++ .../opencode-hooks-bridge.unit.test.ts | 70 +++- .../snapshots/framework-build/golden.json | 4 +- .../interactive-menu-use-case.unit.test.ts | 13 +- docs/ARCHITECTURE.md | 2 +- lefthook.yml | 8 - .../aidd-context/hooks/framework-version.cjs | 301 ++++++++++++++++++ plugins/aidd-context/hooks/hooks.json | 5 + .../assets/templates/AGENTS.md | 2 +- scripts/__tests__/framework-version.test.js | 259 +++++++++++++++ .../__tests__/sync-greeting-version.test.js | 98 ------ scripts/sync-greeting-version.mjs | 78 ----- 22 files changed, 906 insertions(+), 289 deletions(-) delete mode 100644 aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md create mode 100644 aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md create mode 100644 cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-session-context.integration.test.ts create mode 100644 plugins/aidd-context/hooks/framework-version.cjs create mode 100644 scripts/__tests__/framework-version.test.js delete mode 100644 scripts/__tests__/sync-greeting-version.test.js delete mode 100644 scripts/sync-greeting-version.mjs diff --git a/AGENTS.md b/AGENTS.md index 8890b4dd7..a4d9d4a1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v{version} · [Releases](https://github.com/ai-driven-dev/framework/releases)", {version} being the one the `AIDD framework:` session context reports; when it reports none, write "Framework version unknown" in its place, never a plugin or CLI version. When that context announces an update, add that notice verbatim on the next line. ## Behavior @@ -54,7 +54,7 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md -- aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md +- aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md diff --git a/CLAUDE.md b/CLAUDE.md index e8c52ac2f..4e60186c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v{version} · [Releases](https://github.com/ai-driven-dev/framework/releases)", {version} being the one the `AIDD framework:` session context reports; when it reports none, write "Framework version unknown" in its place, never a plugin or CLI version. When that context announces an update, add that notice verbatim on the next line. ## Behavior @@ -54,7 +54,7 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md -- aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md +- aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md diff --git a/aidd_docs/memory/README.md b/aidd_docs/memory/README.md index 23d09e2e0..2670331d4 100644 --- a/aidd_docs/memory/README.md +++ b/aidd_docs/memory/README.md @@ -32,6 +32,7 @@ Read on demand: - [internal/decisions/a-price-table-is-the-destination-s.md](internal/decisions/a-price-table-is-the-destination-s.md) - [internal/decisions/measurement-may-reach-a-hosted-destination.md](internal/decisions/measurement-may-reach-a-hosted-destination.md) +- [internal/decisions/the-greeting-reads-the-installed-framework-version.md](internal/decisions/the-greeting-reads-the-installed-framework-version.md) ## Maintaining it diff --git a/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md b/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md deleted file mode 100644 index d68a4ed9e..000000000 --- a/aidd_docs/memory/internal/decisions/startup-greeting-carries-the-framework-release-version.md +++ /dev/null @@ -1,59 +0,0 @@ -# The startup greeting carries the framework release version, synced by a transformation script - -- Date: 2026-10-25 -- Status: Accepted — implements the visible behavior of #980 -- Decided by: proposed here, for @alexsoyes to confirm or overturn - -## Context - -#980 asks the startup greeting to carry the framework version, directly from the -agent files. Three mechanisms were considered: - -1. **Runtime resolution by the agent.** The agent reads `.claude-plugin/marketplace.json` - at the first message of each conversation. Non-deterministic (the model may skip the - read, hallucinate, or misformat), and in a project installed from the marketplace there - is no `marketplace.json` at the project root, so it displays "Framework version unknown". -2. **Release-time stamping by release-please** (`extra-files` with an - `x-release-please-version` annotation). Deterministic, but it ties the greeting's - upkeep to the release tooling's file list. -3. **A transformation script** (`scripts/sync-greeting-version.mjs`) that reads the - framework release version and stamps it into the greeting of every target file. - -The framework's purpose is to generate information dynamically with tooling, not to ask -the model to resolve it or to hardcode it. - -## Decision - -**A transformation script.** `scripts/sync-greeting-version.mjs` reads the framework -release version from `.claude-plugin/marketplace.json` (the version as cut on the next -branch) and rewrites `Framework v` on the greeting line of every target: - -- `AGENTS.md`, `CLAUDE.md`, `cli/CLAUDE.md` — the framework's own agent files; -- `plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md` — the usage - template shipped to user projects, which becomes their AGENTS.md. - -The script is idempotent and supports `--check` (report instead of write). A lefthook -pre-commit hook (`sync-greeting-version`) runs it on every commit and re-stages the -touched files, so the greeting never drifts from the release. - -The greeting is one canonical line everywhere: - -> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" - -## Consequences - -- Deterministic: the agent repeats the greeting verbatim; no file-reading, no - hallucination risk, no per-conversation resolution tokens. -- The version is dynamic: the script re-stamps it from the release version, and the - pre-commit hook keeps every target in sync at the next commit after the version changes. -- The version is literally in the agent files, and the usage template carries it too: - a project set up from a release greets with the version of that release. -- The "Framework version unknown" fallback is unnecessary: the stamp is always present. -- The shipped template intentionally had no greeting since 4446fff; #980 reinstates it - with the version, stamped by the script. - -## Overturning - -If the version source ever moves out of `.claude-plugin/marketplace.json`, the script's -single read point changes with it. If the greeting format changes, the script's line -match is the single point to update. diff --git a/aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md b/aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md new file mode 100644 index 000000000..d642822c8 --- /dev/null +++ b/aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md @@ -0,0 +1,26 @@ +# The startup greeting reads the installed framework version at session start + +- Date: 2026-10-26 +- Status: Accepted, implements #980 +- Decided by: proposed here, for @alexsoyes to confirm or overturn + +## Context + +#980 wants the greeting to name the framework release a person runs, and to say when a newer one exists. A version stamped into the `AGENTS.md` template is copied once into a project, so it goes stale at the first upgrade and is shared by every teammate whatever each one installed. Asking the model to find the version is non-deterministic, and an installed project holds no marketplace manifest to read. + +## Decision + +`aidd-context` owns one `SessionStart` script, `hooks/framework-version.cjs`, dependency-free: + +- It reads the marketplace version locally, from the installation's own provenance: Claude Code's plugin cache and `known_marketplaces.json`, the `aidd` CLI's `.aidd/manifest.json` and `marketplaces.json`, or a marketplace directory holding the script. Anything else is reported as unknown, never replaced by a plugin or CLI version. +- It hands the version to the conversation as `additionalContext`. The greeting line in the agent files names that context, so the model repeats a value it was given, it does not look one up. +- A newer release is looked up only on the same source and branch: the raw `marketplace.json` of the GitHub repository and ref the installation came from. Local checkouts, pinned tags or commits and other hosts are never compared. The answer is cached for 24 hours in the CLI's own cache directory and refreshed by a detached child, so a session start never waits on the network and a failed fetch never reads as "up to date". +- A known newer release adds a `systemMessage`, shown by Claude Code and Codex without the model, with the update command for that installation mode. Nothing is installed. +- OpenCode runs `SessionStart` at plugin initialization and used to discard its output. The generated bridge now captures it and adds it to every model call: V1 through `experimental.chat.system.transform`, V2 through `session.hook("context")`. + +## Consequences + +- After an upgrade, the next session names the new version, without regenerating `AGENTS.md`. +- The first session after the cache expires learns nothing new; the one after that does. +- On OpenCode, the version is resolved when the server starts, not per conversation in a long-running server. +- Codex's output contract is the same as Claude Code's, but no Codex session was run to prove it. diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md index eabd07e49..ecf90cded 100644 --- a/cli/ARCHITECTURE.md +++ b/cli/ARCHITECTURE.md @@ -108,7 +108,7 @@ Memory ownership (CLAUDE.md, AGENTS.md, copilot-instructions.md) is delegated to ## Hook adaptation -OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization. +OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization; what its scripts print (`additionalContext` or plain text, as Claude Code and Codex read it) is added to every model call through V1's `experimental.chat.system.transform` or V2's `session.hook("context")`.
Tool compatibility and adapter contracts diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index 6206b12be..00fedb8b1 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -1,6 +1,6 @@ # AGENTS.md -> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v{version} · [Releases](https://github.com/ai-driven-dev/framework/releases)", {version} being the one the `AIDD framework:` session context reports; when it reports none, write "Framework version unknown" in its place, never a plugin or CLI version. When that context announces an update, add that notice verbatim on the next line. ## Behavior diff --git a/cli/assets/configs/opencode/opencode-events.js.txt b/cli/assets/configs/opencode/opencode-events.js.txt index da543c0f0..50ad5c34c 100644 --- a/cli/assets/configs/opencode/opencode-events.js.txt +++ b/cli/assets/configs/opencode/opencode-events.js.txt @@ -67,6 +67,22 @@ export async function setupOpencodeEvents(ctx, factory) { const controller = new AbortController(); const toolCalls = new Map(); const hooks = await factory({ directory: ctx.location.directory }); + const registrations = []; + // V1's system transform, replayed on V2's context hook: same text, one block per model call. + const transform = hooks["experimental.chat.system.transform"]; + if (typeof transform === "function" && typeof ctx.session?.hook === "function") { + try { + registrations.push( + await ctx.session.hook("context", async (event) => { + const output = { system: [] }; + await transform({}, output); + for (const text of output.system) event.system.push({ type: "text", text }); + }) + ); + } catch { + // A host without the hook keeps the events below. + } + } // The subscription lives until cleanup. Awaiting it here would block plugin startup. void (async () => { try { @@ -85,5 +101,10 @@ export async function setupOpencodeEvents(ctx, factory) { return () => { controller.abort(); toolCalls.clear(); + for (const registration of registrations) { + Promise.resolve() + .then(() => registration?.dispose?.()) + .catch(() => {}); + } }; } diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts index eb54487c7..800149109 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts @@ -9,7 +9,9 @@ * * - `SessionStart` runs once per server/directory at factory initialization and requires * idempotent commands. V1 did not deliver `session.created` to this event hook; - * the V2 adapter delivers it. + * the V2 adapter delivers it. What the scripts print reaches every model call, the context + * Claude Code and Codex add: V1's `experimental.chat.system.transform`, V2's + * `session.hook("context")` through `opencode-events.js`. * - `Stop` maps to V1's `session.idle` or V2's execution terminal, once per turn. * - `PostToolUse` maps to `message.part.updated` whose `part.state.status === "completed"`, the * shape measured live: `part.tool` names the tool, `part.state.input` its arguments. @@ -131,20 +133,44 @@ const HOOKS_DIR = fileURLToPath(new URL("../hooks/${plugin}/", import.meta.url)) const SESSION_START = ${callTableLiteral(table.sessionStart)}; const STOP = ${callTableLiteral(table.stop)}; const POST_TOOL_USE = ${callTableLiteral(table.postToolUse)}; +const MAX_CONTEXT_CHARS = 16000; // Asynchronous on purpose: this can spawn one script per matching hook across every mapped // event, and blocking OpenCode's event loop once per hook would multiply the cost. A failed spawn (ENOENT, a killed timeout) // must not throw past this function - both listeners below, plus the caller's own // try/catch, exist because a spawn that never launches can still throw on the stdin write. -function runHook(script, args, payload, directory) { - const child = spawn("node", [HOOKS_DIR + script, ...args], { - cwd: directory, - stdio: ["pipe", "ignore", "ignore"], - timeout: 5000, +// Resolves with the script's stdout once it exits, or "" when it never ran. +function runHook(script, args, payload, directory, { captureOutput = false } = {}) { + return new Promise((resolve) => { + const child = spawn("node", [HOOKS_DIR + script, ...args], { + cwd: directory, + stdio: ["pipe", captureOutput ? "pipe" : "ignore", "ignore"], + timeout: 5000, + }); + let output = ""; + child.stdout?.on("data", (chunk) => { + if (output.length < MAX_CONTEXT_CHARS) output += chunk; + }); + child.on("error", () => resolve("")); + child.on("close", () => resolve(output)); + child.stdin.on("error", () => {}); + child.stdin.end(JSON.stringify(payload)); }); - child.on("error", () => {}); - child.stdin.on("error", () => {}); - child.stdin.end(JSON.stringify(payload)); +} + +/** Pure: a SessionStart script's stdout -> the context Claude Code and Codex would add for it: + * \`hookSpecificOutput.additionalContext\` from JSON, plain text as is, "" for nothing. A + * \`systemMessage\` has no OpenCode surface, and the context already carries what it says. */ +function sessionContextOf(output) { + const text = output.trim(); + if (text === "") return ""; + try { + const parsed = JSON.parse(text); + const context = parsed?.hookSpecificOutput?.additionalContext; + return typeof context === "string" ? context.trim() : ""; + } catch { + return text; + } } /** Pure: \`session.idle\` -> every Stop hook's own {script, args, payload} - or \`[]\` for @@ -187,19 +213,32 @@ export const ${ident} = async (input) => { // SessionStart's own approximation (module doc comment above): fired once here, never // per session. Silent on purpose: a memory refresh // that breaks OpenCode's own startup is worse than one that never ran. + let sessionContext = Promise.resolve(""); try { - for (const hook of SESSION_START) { - runHook( - hook.script, - hook.args, - { hook_event_name: "SessionStart", session_id: null, cwd: input.directory }, - input.directory - ); - } + sessionContext = Promise.all( + SESSION_START.map((hook) => + runHook( + hook.script, + hook.args, + { hook_event_name: "SessionStart", session_id: null, cwd: input.directory }, + input.directory, + { captureOutput: true } + ) + ) + ) + .then((outputs) => outputs.map(sessionContextOf).filter(Boolean).join("\\n\\n")) + .catch(() => ""); } catch { // Silent on purpose - see above. } return { + // What SessionStart printed reaches every model call, as Claude Code and Codex add it to + // the conversation. V1 calls this per request; V2 reaches it through opencode-events.js. + // \`sessionContext\` never rejects, so a model call never fails over it. + "experimental.chat.system.transform": async (_input, output) => { + const context = await sessionContext; + if (context && Array.isArray(output?.system)) output.system.push(context); + }, event: async ({ event }) => { try { const directory = event?.properties?.directory ?? input.directory; @@ -219,6 +258,7 @@ export const ${ident} = async (input) => { ${ident}.stopCallsFor = stopCallsFor; ${ident}.postToolUseCallsFor = postToolUseCallsFor; +${ident}.sessionContextOf = sessionContextOf; export default { id: ${JSON.stringify(`${plugin}-hooks`)}, diff --git a/cli/src/presentation/prompts/menu-use-case.ts b/cli/src/presentation/prompts/menu-use-case.ts index b58ae19d3..f7122460d 100644 --- a/cli/src/presentation/prompts/menu-use-case.ts +++ b/cli/src/presentation/prompts/menu-use-case.ts @@ -201,8 +201,14 @@ const INSTALLED_NODES: MenuNode[] = [ { name: "System", value: "system", - description: "CLI update and authentication", + description: "CLI version, update and authentication", children: [ + { + name: "CLI version", + value: "self-update-check", + description: "Show the installed CLI version and whether a newer one is published", + command: ["update", "--check"], + }, { name: "Update CLI", value: "self-update", diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-session-context.integration.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-session-context.integration.test.ts new file mode 100644 index 000000000..c74008422 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-session-context.integration.test.ts @@ -0,0 +1,154 @@ +// What a SessionStart script prints only reaches OpenCode through the generated module's own +// spawn and its V1/V2 entrypoints, so proving it means writing, importing and running that file. +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { generateOpencodeHooksBridge } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.js"; +import { REPOSITORY_ROOT } from "../../../../../helpers/repository-root.js"; + +// Built rather than written as a literal "${CLAUDE_PLUGIN_ROOT}" string: biome reads a plain +// string holding "${...}" as a forgotten template literal. +const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +const HOOKS_JSON = JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { type: "command", command: `node ${ROOT}/hooks/as-json.cjs` }, + { type: "command", command: `node ${ROOT}/hooks/as-text.cjs` }, + { type: "command", command: `node ${ROOT}/hooks/silent.cjs` }, + ], + }, + ], + }, +}); + +const SCRIPTS: Record = { + "as-json.cjs": `process.stdout.write(JSON.stringify({ + systemMessage: "shown to the person", + hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "AIDD framework: v9.9.9" }, + }));`, + "as-text.cjs": `process.stdout.write("plain context\\n");`, + "silent.cjs": "", +}; + +const EXPECTED_CONTEXT = "AIDD framework: v9.9.9\n\nplain context"; + +type SystemTransform = (input: unknown, output: { system: string[] }) => Promise; +type ContextHook = (event: { system: unknown[] }) => Promise; + +interface GeneratedModule { + factory: (input: { directory: string }) => Promise>; + setup: (ctx: unknown) => Promise<() => void>; + sessionContextOf: (output: string) => string; + directory: string; +} + +const tempDirs: string[] = []; +afterEach(async () => { + for (const dir of tempDirs.splice(0)) execFileSync("rm", ["-rf", dir]); +}); + +async function importGeneratedModule(): Promise { + const generated = generateOpencodeHooksBridge(HOOKS_JSON, "aidd-sample"); + if (generated === null) throw new Error("expected a generated module"); + const dir = await mkdtemp(join(tmpdir(), "aidd-opencode-bridge-context-")); + tempDirs.push(dir); + await mkdir(join(dir, "plugin")); + await mkdir(join(dir, "hooks", "aidd-sample"), { recursive: true }); + await writeFile( + join(dir, "hooks", "opencode-events.js"), + await readFile( + join(REPOSITORY_ROOT, "cli/assets/configs/opencode/opencode-events.js.txt"), + "utf8" + ) + ); + for (const [name, body] of Object.entries(SCRIPTS)) { + await writeFile(join(dir, "hooks", "aidd-sample", name), body, "utf8"); + } + const modulePath = join(dir, "plugin", "bridge.mjs"); + await writeFile(modulePath, generated, "utf8"); + const mod: Record = await import(pathToFileURL(modulePath).href); + const factory = mod.AiddSampleHooks as GeneratedModule["factory"]; + const plugin = mod.default as { setup: GeneratedModule["setup"] }; + const sessionContextOf = Reflect.get(factory, "sessionContextOf") as (o: string) => string; + return { factory, setup: plugin.setup, sessionContextOf, directory: dir }; +} + +describe("the generated bridge hands SessionStart's output to every model call", () => { + it("V1: the system transform adds each script's context, JSON and plain text alike", async () => { + const { factory, directory } = await importGeneratedModule(); + const hooks = await factory({ directory }); + const transform = hooks["experimental.chat.system.transform"] as SystemTransform; + + const first = { system: ["base"] }; + await transform({}, first); + const second = { system: [] as string[] }; + await transform({}, second); + + expect(first.system).toEqual(["base", EXPECTED_CONTEXT]); + expect(second.system).toEqual([EXPECTED_CONTEXT]); + }); + + it("V2: setup registers a context hook pushing the same text as a system block", async () => { + const { setup, directory } = await importGeneratedModule(); + const registered: Record = {}; + let disposed = 0; + const ctx = { + location: { directory }, + event: { + subscribe: async function* () { + // No event: only the context hook is under test. + }, + }, + session: { + hook: async (name: string, callback: ContextHook) => { + registered[name] = callback; + return { + dispose: async () => { + disposed += 1; + }, + }; + }, + }, + }; + + const cleanup = await setup(ctx); + const event = { system: [] as unknown[] }; + await registered.context?.(event); + cleanup(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(event.system).toEqual([{ type: "text", text: EXPECTED_CONTEXT }]); + expect(disposed).toBe(1); + }); + + it("V2 without a session hook API still sets up, adding nothing", async () => { + const { setup, directory } = await importGeneratedModule(); + const ctx = { + location: { directory }, + event: { + subscribe: async function* () { + // No event. + }, + }, + }; + + await expect(setup(ctx)).resolves.toBeTypeOf("function"); + }); + + it("reads a script's output the way Claude Code and Codex do", async () => { + const { sessionContextOf } = await importGeneratedModule(); + + expect(sessionContextOf("")).toBe(""); + expect(sessionContextOf(" plain \n")).toBe("plain"); + expect( + sessionContextOf(JSON.stringify({ hookSpecificOutput: { additionalContext: " ctx " } })) + ).toBe("ctx"); + expect(sessionContextOf(JSON.stringify({ systemMessage: "only shown" }))).toBe(""); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts index 54d2e32da..5bbb769a1 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts @@ -53,20 +53,44 @@ describe("generateOpencodeHooksBridge", () => { const SESSION_START = [{"script":"update_memory.js","args":[]}]; const STOP = [{"script":"journal.cjs","args":["turn-end"]}]; const POST_TOOL_USE = [{"script":"journal.cjs","args":["tool-used"],"matcher":"Bash"}]; + const MAX_CONTEXT_CHARS = 16000; // Asynchronous on purpose: this can spawn one script per matching hook across every mapped // event, and blocking OpenCode's event loop once per hook would multiply the cost. A failed spawn (ENOENT, a killed timeout) // must not throw past this function - both listeners below, plus the caller's own // try/catch, exist because a spawn that never launches can still throw on the stdin write. - function runHook(script, args, payload, directory) { - const child = spawn("node", [HOOKS_DIR + script, ...args], { - cwd: directory, - stdio: ["pipe", "ignore", "ignore"], - timeout: 5000, + // Resolves with the script's stdout once it exits, or "" when it never ran. + function runHook(script, args, payload, directory, { captureOutput = false } = {}) { + return new Promise((resolve) => { + const child = spawn("node", [HOOKS_DIR + script, ...args], { + cwd: directory, + stdio: ["pipe", captureOutput ? "pipe" : "ignore", "ignore"], + timeout: 5000, + }); + let output = ""; + child.stdout?.on("data", (chunk) => { + if (output.length < MAX_CONTEXT_CHARS) output += chunk; + }); + child.on("error", () => resolve("")); + child.on("close", () => resolve(output)); + child.stdin.on("error", () => {}); + child.stdin.end(JSON.stringify(payload)); }); - child.on("error", () => {}); - child.stdin.on("error", () => {}); - child.stdin.end(JSON.stringify(payload)); + } + + /** Pure: a SessionStart script's stdout -> the context Claude Code and Codex would add for it: + * \`hookSpecificOutput.additionalContext\` from JSON, plain text as is, "" for nothing. A + * \`systemMessage\` has no OpenCode surface, and the context already carries what it says. */ + function sessionContextOf(output) { + const text = output.trim(); + if (text === "") return ""; + try { + const parsed = JSON.parse(text); + const context = parsed?.hookSpecificOutput?.additionalContext; + return typeof context === "string" ? context.trim() : ""; + } catch { + return text; + } } /** Pure: \`session.idle\` -> every Stop hook's own {script, args, payload} - or \`[]\` for @@ -109,19 +133,32 @@ describe("generateOpencodeHooksBridge", () => { // SessionStart's own approximation (module doc comment above): fired once here, never // per session. Silent on purpose: a memory refresh // that breaks OpenCode's own startup is worse than one that never ran. + let sessionContext = Promise.resolve(""); try { - for (const hook of SESSION_START) { - runHook( - hook.script, - hook.args, - { hook_event_name: "SessionStart", session_id: null, cwd: input.directory }, - input.directory - ); - } + sessionContext = Promise.all( + SESSION_START.map((hook) => + runHook( + hook.script, + hook.args, + { hook_event_name: "SessionStart", session_id: null, cwd: input.directory }, + input.directory, + { captureOutput: true } + ) + ) + ) + .then((outputs) => outputs.map(sessionContextOf).filter(Boolean).join("\\n\\n")) + .catch(() => ""); } catch { // Silent on purpose - see above. } return { + // What SessionStart printed reaches every model call, as Claude Code and Codex add it to + // the conversation. V1 calls this per request; V2 reaches it through opencode-events.js. + // \`sessionContext\` never rejects, so a model call never fails over it. + "experimental.chat.system.transform": async (_input, output) => { + const context = await sessionContext; + if (context && Array.isArray(output?.system)) output.system.push(context); + }, event: async ({ event }) => { try { const directory = event?.properties?.directory ?? input.directory; @@ -141,6 +178,7 @@ describe("generateOpencodeHooksBridge", () => { AiddSampleHooks.stopCallsFor = stopCallsFor; AiddSampleHooks.postToolUseCallsFor = postToolUseCallsFor; + AiddSampleHooks.sessionContextOf = sessionContextOf; export default { id: "aidd-sample-hooks", diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index e4c771321..81f876634 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -1748,8 +1748,8 @@ ".opencode/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", - ".opencode/plugin/aidd-context-hooks.js": "4d4de407a2afae13045eaf8291efac247717f9fdbe7d983be9f977eb947e4b6e", - ".opencode/hooks/opencode-events.js": "ce9363e18f1f5b11c401a30bd1d8643dcdd72ced0182d97baae763276216babf", + ".opencode/plugin/aidd-context-hooks.js": "d4d8306175cb6c99b7ec6ac12b9f31760934793c7f4e27db114cab5fedeae1a7", + ".opencode/hooks/opencode-events.js": "064e0a75bc487be857fe9ee475773144090b076578cc4cb73a671becaef65c0f", ".opencode/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", ".opencode/agents/aidd-async-dev-async-orchestrator.md": "3eb709fb7da8f6df7d4d76c7c69fce0a00b596d7b9522b5dcf3898c7a94d91cf", ".opencode/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", diff --git a/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts b/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts index fa4015ae1..4adabf92a 100644 --- a/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts +++ b/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts @@ -248,7 +248,7 @@ describe("interactive menu — the rows each level offers", () => { value: "maintain", description: "Update tools, sync tracked files, and clean everything", }, - { name: "System", value: "system", description: "CLI update and authentication" }, + { name: "System", value: "system", description: "CLI version, update and authentication" }, { name: "Exit", value: "exit" }, ], ]); @@ -395,10 +395,15 @@ describe("interactive menu — the rows each level offers", () => { ]); }); - it("offers the CLI's own update and a nested authentication branch under System", async () => { + it("offers the CLI's version, its own update and a nested authentication branch under System", async () => { expect(await choicesAt(["system"])).toEqual([ "System", [ + { + name: "CLI version", + value: "self-update-check", + description: "Show the installed CLI version and whether a newer one is published", + }, { name: "Update CLI", value: "self-update", @@ -482,6 +487,10 @@ describe("interactive menu — the command each pick hands over", () => { ], [["maintain", "sync-all"], ["sync"]], [["maintain", "clean"], ["clean"]], + [ + ["system", "self-update-check"], + ["update", "--check"], + ], [["system", "self-update"], ["update"]], [ ["system", "auth", "auth-status"], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8976344d..03ed5cbb1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,7 +114,7 @@ Dependency-free Node scripts declared in `hooks/hooks.json`; `node` must be on ` | Plugin | Event | Runs | Purpose | | ---------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------- | -| `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | +| `aidd-context` | `SessionStart` | `hooks/update_memory.js` · `hooks/framework-version.cjs` | Refresh the project memory block in the AI context files; tell the conversation the installed framework release for the greeting, and announce a newer one from the same source, cached and refreshed in the background | | `aidd-telemetry` | `SessionStart` · `UserPromptSubmit` | `hooks/session-start.cjs` · `hooks/catch-up.cjs` · `hooks/prompt-gate.cjs` | At session start, record which session a process is on and keep its task across `/clear`, and catch the ledger up (async); on a prompt, hold it on an undeclared branch until a task is declared, and answer the declaration without the model | ## Portability diff --git a/lefthook.yml b/lefthook.yml index 003f75594..6972197ae 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -149,14 +149,6 @@ pre-commit: fi node scripts/sync-readme-counts.mjs >/dev/null git add README.md 2>/dev/null || true - sync-greeting-version: - run: | - if ! command -v node >/dev/null 2>&1; then - echo 'ℹ️ node not available; skipping sync-greeting-version' - exit 0 - fi - node scripts/sync-greeting-version.mjs >/dev/null - git add AGENTS.md CLAUDE.md cli/CLAUDE.md plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md 2>/dev/null || true cli-biome: glob: "cli/**" run: cd cli && pnpm lint diff --git a/plugins/aidd-context/hooks/framework-version.cjs b/plugins/aidd-context/hooks/framework-version.cjs new file mode 100644 index 000000000..c9f4bcad7 --- /dev/null +++ b/plugins/aidd-context/hooks/framework-version.cjs @@ -0,0 +1,301 @@ +#!/usr/bin/env node +/** + * SessionStart: tells the conversation which AIDD framework release this installation runs, so + * the startup greeting can name it, and whether a newer release of the same source is known. + * + * The version is the marketplace's own (`.claude-plugin/marketplace.json`), never a plugin's or + * the CLI's: the three are versioned apart. It is read locally, from wherever this installation + * came from: + * + * - Claude Code's plugin cache: `/cache////`, resolved + * through `/known_marketplaces.json` to the marketplace it was installed from; + * - an `aidd` CLI install copied into the project: its `.aidd/manifest.json`, then the + * `marketplaces.json` that registered the plugin's marketplace, project scope first; + * - a marketplace directory holding this script: a local checkout, or the framework itself. + * + * Anything else is reported as unknown rather than guessed. + * + * Discovering a newer release needs the network, which a session start never waits on: only a + * cached answer is read here, and a stale one is refreshed by a detached child for the next + * session. A source compared remotely is a GitHub repository on a branch; a local checkout, a + * pinned tag or commit, or any other host is never compared, so nothing claims an installation + * is up to date when it was not checked. + */ + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +const MANIFEST = path.join(".claude-plugin", "marketplace.json"); +const OWNING_PLUGIN = "aidd-context"; +const RELEASES_URL = "https://github.com/ai-driven-dev/framework/releases"; +const CHECK_TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 5000; +const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/; +// A ref naming a release, not a branch: following it is the person's choice, not an update. +const PINNED_REF = /(^|-)v?\d+\.\d+\.\d+$|^[0-9a-f]{40}$/; + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return null; + } +} + +function marketplaceAt(dir) { + const manifest = readJson(path.join(dir, MANIFEST)); + if (manifest === null || !Array.isArray(manifest.plugins)) return null; + if (!manifest.plugins.some((p) => p?.name === OWNING_PLUGIN)) return null; + return manifest; +} + +function semverOrNull(value) { + return typeof value === "string" && SEMVER.test(value) ? value : null; +} + +function compareSemver(a, b) { + const pa = SEMVER.exec(a).slice(1).map(Number); + const pb = SEMVER.exec(b).slice(1).map(Number); + for (let i = 0; i < 3; i += 1) if (pa[i] !== pb[i]) return pa[i] - pb[i]; + return 0; +} + +/** The same directory the CLI resolves, so a machine-scope `aidd clean` purges this cache too. */ +function userConfigDir(env) { + if (env.AIDD_USER_CONFIG_DIR) return env.AIDD_USER_CONFIG_DIR; + if (env.XDG_CONFIG_HOME) return path.join(env.XDG_CONFIG_HOME, "aidd"); + return path.join(os.homedir(), ".config", "aidd"); +} + +/** `owner/repo` of a github.com remote, or null. */ +function githubRepoOf(url) { + const match = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(url ?? ""); + return match ? match[1] : null; +} + +/** Claude Code's and the CLI's source shapes, reduced to what a comparison needs. */ +function normalizeSource(raw) { + if (raw === null || typeof raw !== "object") return null; + const kind = raw.source ?? raw.kind; + if (kind === "directory" || kind === "local") return { kind: "local", path: raw.path }; + if (kind === "github") return { kind: "github", repo: raw.repo, ref: raw.ref, sha: raw.sha }; + if (kind === "git" || kind === "url") { + const repo = githubRepoOf(raw.url); + if (repo !== null) return { kind: "github", repo, ref: raw.ref, sha: raw.sha }; + // A Claude Code `url` source points at the manifest itself. + if (kind === "url" && /marketplace\.json$/.test(raw.url ?? "")) { + return { kind: "manifest-url", url: raw.url }; + } + return { kind: "other", url: raw.url }; + } + return null; +} + +function fromClaudeCache(pluginRoot) { + const parts = pluginRoot.split(path.sep); + const at = parts.lastIndexOf("cache"); + if (at < 1 || parts.length !== at + 4) return null; + const pluginsDir = parts.slice(0, at).join(path.sep); + const name = parts[at + 1]; + const known = readJson(path.join(pluginsDir, "known_marketplaces.json")); + const entry = known?.[name]; + if (!entry || typeof entry.installLocation !== "string") return null; + const manifest = marketplaceAt(entry.installLocation); + return { + mode: "claude", + marketplace: name, + version: semverOrNull(manifest?.version), + source: normalizeSource(entry.source), + }; +} + +function fromAncestorMarketplace(start) { + let dir = start; + for (;;) { + const manifest = marketplaceAt(dir); + if (manifest !== null) { + return { + mode: "local", + marketplace: manifest.name ?? null, + version: semverOrNull(manifest.version), + source: { kind: "local", path: dir }, + }; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function fromAiddCli(projectRoot, env) { + const manifest = readJson(path.join(projectRoot, ".aidd", "manifest.json")); + const tools = manifest?.tools && typeof manifest.tools === "object" ? manifest.tools : {}; + const name = Object.values(tools) + .flatMap((tool) => (Array.isArray(tool?.plugins) ? tool.plugins : [])) + .find((plugin) => plugin?.name === OWNING_PLUGIN)?.marketplace; + if (typeof name !== "string") return null; + const registries = [ + path.join(projectRoot, ".aidd", "marketplaces.json"), + path.join(userConfigDir(env), "marketplaces.json"), + ]; + for (const file of registries) { + const entry = readJson(file)?.marketplaces?.find?.((m) => m?.name === name); + if (!entry) continue; + return { + mode: "aidd", + marketplace: name, + version: semverOrNull(entry.version), + source: normalizeSource(entry.source), + }; + } + return null; +} + +function isInside(child, parent) { + const rel = path.relative(parent, child); + return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); +} + +/** A copy the CLI wrote into the project answers to the CLI's record of where it came from, + * before any marketplace that happens to sit above the project. */ +function resolveInstallation({ scriptDir, projectRoot, env }) { + const unknown = { mode: "unknown", marketplace: null, version: null, source: null }; + const cliCopy = isInside(scriptDir, projectRoot) ? fromAiddCli(projectRoot, env) : null; + return ( + fromClaudeCache(path.dirname(scriptDir)) ?? cliCopy ?? fromAncestorMarketplace(scriptDir) ?? unknown + ); +} + +/** Where the newest release on the installation's own source and channel is published, or null + * when that source is not one to compare against. */ +function releaseManifestUrl(source) { + if (source?.kind === "manifest-url") return source.url; + if (source?.kind !== "github" || typeof source.repo !== "string") return null; + if (source.sha || (source.ref && PINNED_REF.test(source.ref))) return null; + return `https://raw.githubusercontent.com/${source.repo}/${source.ref || "HEAD"}/${MANIFEST.split(path.sep).join("/")}`; +} + +function cacheFile(env) { + return path.join(userConfigDir(env), "cache", "framework-release-check.json"); +} + +function readCache(env) { + const cache = readJson(cacheFile(env)); + return cache && typeof cache === "object" ? cache : {}; +} + +function writeCacheEntry(env, url, entry) { + const file = cacheFile(env); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const cache = readCache(env); + cache[url] = entry; + fs.writeFileSync(file, JSON.stringify(cache)); +} + +/** The cached newest release, refreshed in the background when stale. Never waits on the + * network: a first session learns nothing, the next one reads what the refresh found. */ +function knownLatest(url, env, now) { + const entry = readCache(env)[url]; + const latest = semverOrNull(entry?.latest); + if (typeof entry?.checkedAt === "number" && now - entry.checkedAt < CHECK_TTL_MS) return latest; + try { + // Claimed before the child runs, so concurrent sessions start one refresh, not one each. + writeCacheEntry(env, url, { checkedAt: now, latest }); + const child = spawn(process.execPath, [__filename, "--refresh", url], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.on("error", () => {}); + child.unref(); + } catch { + // An unwritable cache or a failed spawn only costs the notice. + } + return latest; +} + +async function refresh(url, env) { + const previous = semverOrNull(readCache(env)[url]?.latest); + let latest = previous; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (response.ok) latest = semverOrNull((await response.json())?.version) ?? previous; + } catch { + // Unreachable is not "up to date": the previous answer, or none, stands. + } + writeCacheEntry(env, url, { checkedAt: Date.now(), latest }); +} + +function updateInstruction(installation) { + const name = installation.marketplace ?? "aidd-framework"; + if (installation.mode === "claude") { + return `run \`/plugin marketplace update ${name}\`, then update the AIDD plugins from \`/plugin\``; + } + if (installation.mode === "aidd") { + return `run \`aidd marketplace refresh ${name}\`, then \`aidd plugin update\``; + } + return `see ${RELEASES_URL}`; +} + +function describeSource(source) { + if (source?.kind === "github") return `github.com/${source.repo}${source.ref ? `@${source.ref}` : ""}`; + if (source?.kind === "local") return `local directory ${source.path}`; + if (source?.kind === "manifest-url" || source?.kind === "other") return source.url; + return "an unrecorded source"; +} + +/** The hook's whole answer: what the model is told, and what the person is shown directly. */ +function startupReport({ scriptDir, projectRoot, env, now }) { + const installation = resolveInstallation({ scriptDir, projectRoot, env }); + if (installation.version === null) { + return { + context: + "AIDD framework: version unknown (no marketplace manifest found for this installation). " + + "Greet with `Framework version unknown`; never substitute a plugin or CLI version.", + notice: null, + }; + } + const lines = [ + `AIDD framework: v${installation.version}, marketplace ${installation.marketplace ?? "unnamed"}, ` + + `installed from ${describeSource(installation.source)}.`, + ]; + const url = releaseManifestUrl(installation.source); + const latest = url === null ? null : knownLatest(url, env, now); + let notice = null; + if (latest !== null && compareSemver(latest, installation.version) > 0) { + notice = + `AIDD framework v${latest} is available (installed: v${installation.version}). ` + + `To update, ${updateInstruction(installation)}. Nothing is installed automatically.`; + lines.push( + `AIDD framework update: ${notice} This is the marketplace release; each plugin keeps its own version.` + ); + } + return { context: lines.join("\n"), notice }; +} + +function main() { + const env = process.env; + if (process.argv[2] === "--refresh") { + refresh(process.argv[3], env).catch(() => {}); + return; + } + try { + const report = startupReport({ + scriptDir: __dirname, + projectRoot: process.cwd(), + env, + now: Date.now(), + }); + const output = { + hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: report.context }, + }; + if (report.notice !== null) output.systemMessage = report.notice; + process.stdout.write(JSON.stringify(output)); + } catch { + // A version line is never worth a failed session start. + } +} + +main(); diff --git a/plugins/aidd-context/hooks/hooks.json b/plugins/aidd-context/hooks/hooks.json index 76b2fb90b..94c400103 100644 --- a/plugins/aidd-context/hooks/hooks.json +++ b/plugins/aidd-context/hooks/hooks.json @@ -6,6 +6,11 @@ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/update_memory.js" + }, + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/framework-version.cjs", + "timeout": 10 } ] } diff --git a/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md b/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md index 217591ff6..a002b60b3 100644 --- a/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md +++ b/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v5.12.0 · [Releases](https://github.com/ai-driven-dev/framework/releases)" +> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v{version} · [Releases](https://github.com/ai-driven-dev/framework/releases)", {version} being the one the `AIDD framework:` session context reports; when it reports none, write "Framework version unknown" in its place, never a plugin or CLI version. When that context announces an update, add that notice verbatim on the next line. ## Behavior diff --git a/scripts/__tests__/framework-version.test.js b/scripts/__tests__/framework-version.test.js new file mode 100644 index 000000000..bcbde096f --- /dev/null +++ b/scripts/__tests__/framework-version.test.js @@ -0,0 +1,259 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const http = require("node:http"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +// The hook is a script the hosts copy, so each case copies it into the layout one installation +// mode leaves on disk and runs it as a subprocess, which is what ships. +const SCRIPT = path.resolve(__dirname, "../../plugins/aidd-context/hooks/framework-version.cjs"); +const GITHUB_MANIFEST_URL = + "https://raw.githubusercontent.com/ai-driven-dev/framework/HEAD/.claude-plugin/marketplace.json"; + +function sandbox(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-framework-version-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function write(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, typeof content === "string" ? content : JSON.stringify(content)); +} + +function marketplace(version) { + return { name: "aidd-framework", version, plugins: [{ name: "aidd-context", source: "./x" }] }; +} + +function installScript(hooksDir) { + fs.mkdirSync(hooksDir, { recursive: true }); + const target = path.join(hooksDir, "framework-version.cjs"); + fs.copyFileSync(SCRIPT, target); + return target; +} + +function run(script, { cwd, configDir }) { + const result = spawnSync(process.execPath, [script], { + cwd, + env: { PATH: process.env.PATH, AIDD_USER_CONFIG_DIR: configDir }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +function seedCache(configDir, url, latest, checkedAt = Date.now()) { + write(path.join(configDir, "cache", "framework-release-check.json"), { [url]: { checkedAt, latest } }); +} + +function readCache(configDir) { + return JSON.parse(fs.readFileSync(path.join(configDir, "cache", "framework-release-check.json"), "utf8")); +} + +/** Claude Code's layout: the plugin copied into its cache, the marketplace cloned beside it. */ +function claudeInstall(root, { version, source }) { + const plugins = path.join(root, "claude", "plugins"); + const clone = path.join(plugins, "marketplaces", "aidd-framework"); + write(path.join(clone, ".claude-plugin", "marketplace.json"), marketplace(version)); + write(path.join(plugins, "known_marketplaces.json"), { + "aidd-framework": { source, installLocation: clone }, + }); + return installScript(path.join(plugins, "cache", "aidd-framework", "aidd-context", "2.9.0", "hooks")); +} + +test("a Claude Code install names the marketplace's version and where it came from", (t) => { + const root = sandbox(t); + const script = claudeInstall(root, { + version: "5.12.0", + source: { source: "github", repo: "ai-driven-dev/framework" }, + }); + const configDir = path.join(root, "config"); + seedCache(configDir, GITHUB_MANIFEST_URL, "5.12.0"); + + const output = run(script, { cwd: root, configDir }); + + assert.equal(output.hookSpecificOutput.hookEventName, "SessionStart"); + assert.equal( + output.hookSpecificOutput.additionalContext, + "AIDD framework: v5.12.0, marketplace aidd-framework, installed from github.com/ai-driven-dev/framework." + ); + assert.equal(output.systemMessage, undefined); +}); + +test("a newer release on the same source is announced with the host's update path, never installed", (t) => { + const root = sandbox(t); + const script = claudeInstall(root, { + version: "5.12.0", + source: { source: "github", repo: "ai-driven-dev/framework" }, + }); + const configDir = path.join(root, "config"); + seedCache(configDir, GITHUB_MANIFEST_URL, "5.13.0"); + + const output = run(script, { cwd: root, configDir }); + + assert.equal( + output.systemMessage, + "AIDD framework v5.13.0 is available (installed: v5.12.0). To update, run " + + "`/plugin marketplace update aidd-framework`, then update the AIDD plugins from `/plugin`. " + + "Nothing is installed automatically." + ); + assert.match(output.hookSpecificOutput.additionalContext, /AIDD framework update: .*v5\.13\.0/); + assert.match(output.hookSpecificOutput.additionalContext, /each plugin keeps its own version/); +}); + +test("an older or equal cached release announces nothing", (t) => { + const root = sandbox(t); + const script = claudeInstall(root, { + version: "5.12.0", + source: { source: "github", repo: "ai-driven-dev/framework" }, + }); + const configDir = path.join(root, "config"); + seedCache(configDir, GITHUB_MANIFEST_URL, "5.11.0"); + + assert.equal(run(script, { cwd: root, configDir }).systemMessage, undefined); +}); + +test("a stale check claims the refresh and still answers from what was known", (t) => { + const root = sandbox(t); + // A manifest URL on a closed local port: the detached refresh this starts never leaves the box. + const url = "http://127.0.0.1:9/.claude-plugin/marketplace.json"; + const script = claudeInstall(root, { version: "5.12.0", source: { source: "url", url } }); + const configDir = path.join(root, "config"); + const stale = Date.now() - 2 * 24 * 60 * 60 * 1000; + seedCache(configDir, url, "5.13.0", stale); + + const output = run(script, { cwd: root, configDir }); + + assert.match(output.systemMessage, /v5\.13\.0 is available/); + const entry = readCache(configDir)[url]; + assert.ok(entry.checkedAt > stale, "the stale entry is claimed so concurrent sessions do not refresh too"); +}); + +test("a local marketplace directory is its own channel: version read, never compared", (t) => { + const root = sandbox(t); + write(path.join(root, "checkout", ".claude-plugin", "marketplace.json"), marketplace("5.12.0")); + const script = installScript(path.join(root, "checkout", "plugins", "aidd-context", "hooks")); + const configDir = path.join(root, "config"); + + const output = run(script, { cwd: root, configDir }); + + assert.equal( + output.hookSpecificOutput.additionalContext, + `AIDD framework: v5.12.0, marketplace aidd-framework, installed from local directory ${path.join(root, "checkout")}.` + ); + assert.equal(fs.existsSync(path.join(configDir, "cache")), false); +}); + +test("an aidd CLI copy reads the CLI's record, and a pinned release is never compared", (t) => { + const root = sandbox(t); + const project = path.join(root, "project"); + write(path.join(project, ".aidd", "manifest.json"), { + version: 8, + tools: { opencode: { plugins: [{ name: "aidd-context", marketplace: "aidd-framework" }] } }, + }); + write(path.join(project, ".aidd", "marketplaces.json"), { + version: 1, + marketplaces: [ + { + name: "aidd-framework", + source: { kind: "github", repo: "ai-driven-dev/framework", ref: "v5.12.0" }, + version: "5.12.0", + }, + ], + }); + const script = installScript(path.join(project, ".opencode", "hooks", "aidd-context")); + const configDir = path.join(root, "config"); + + const output = run(script, { cwd: project, configDir }); + + assert.equal( + output.hookSpecificOutput.additionalContext, + "AIDD framework: v5.12.0, marketplace aidd-framework, installed from github.com/ai-driven-dev/framework@v5.12.0." + ); + assert.equal(fs.existsSync(path.join(configDir, "cache")), false); +}); + +test("an aidd CLI copy registered at user scope announces the CLI's own update path", (t) => { + const root = sandbox(t); + const project = path.join(root, "project"); + const configDir = path.join(root, "config"); + write(path.join(project, ".aidd", "manifest.json"), { + version: 8, + tools: { codex: { plugins: [{ name: "aidd-context", marketplace: "aidd-framework" }] } }, + }); + write(path.join(configDir, "marketplaces.json"), { + version: 1, + marketplaces: [ + { name: "aidd-framework", source: { kind: "url", url: "https://github.com/ai-driven-dev/framework.git" }, version: "5.12.0" }, + ], + }); + seedCache(configDir, GITHUB_MANIFEST_URL, "5.13.0"); + const script = installScript(path.join(project, ".codex", "hooks", "aidd-context")); + + const output = run(script, { cwd: project, configDir }); + + assert.match(output.systemMessage, /run `aidd marketplace refresh aidd-framework`, then `aidd plugin update`/); +}); + +test("an installation with no manifest anywhere says unknown, never another version", (t) => { + const root = sandbox(t); + const script = installScript(path.join(root, "loose", "hooks")); + + const output = run(script, { cwd: root, configDir: path.join(root, "config") }); + + assert.match(output.hookSpecificOutput.additionalContext, /^AIDD framework: version unknown/); + assert.match(output.hookSpecificOutput.additionalContext, /Framework version unknown/); + assert.equal(output.systemMessage, undefined); +}); + +function serve(t, handler) { + return new Promise((resolve) => { + const server = http.createServer(handler); + server.listen(0, "127.0.0.1", () => { + t.after(() => server.close()); + resolve(`http://127.0.0.1:${server.address().port}/.claude-plugin/marketplace.json`); + }); + }); +} + +function refresh(url, configDir) { + return new Promise((resolve, reject) => { + const child = require("node:child_process").spawn(process.execPath, [SCRIPT, "--refresh", url], { + env: { PATH: process.env.PATH, AIDD_USER_CONFIG_DIR: configDir }, + stdio: "ignore", + }); + child.on("error", reject); + child.on("close", resolve); + }); +} + +test("the background refresh records the newest release its source publishes", async (t) => { + const root = sandbox(t); + const configDir = path.join(root, "config"); + const url = await serve(t, (_req, res) => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(marketplace("5.14.0"))); + }); + + await refresh(url, configDir); + + assert.equal(readCache(configDir)[url].latest, "5.14.0"); +}); + +test("an unreachable source keeps what was known instead of claiming up to date", async (t) => { + const root = sandbox(t); + const configDir = path.join(root, "config"); + const url = await serve(t, (_req, res) => { + res.statusCode = 503; + res.end(); + }); + seedCache(configDir, url, "5.13.0", 0); + + await refresh(url, configDir); + + const entry = readCache(configDir)[url]; + assert.equal(entry.latest, "5.13.0"); + assert.ok(entry.checkedAt > 0); +}); diff --git a/scripts/__tests__/sync-greeting-version.test.js b/scripts/__tests__/sync-greeting-version.test.js deleted file mode 100644 index 0b3ec674b..000000000 --- a/scripts/__tests__/sync-greeting-version.test.js +++ /dev/null @@ -1,98 +0,0 @@ -const assert = require("node:assert/strict"); -const { spawnSync } = require("node:child_process"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); - -const SCRIPT = path.resolve(__dirname, "..", "sync-greeting-version.mjs"); - -const TARGETS = [ - "AGENTS.md", - "CLAUDE.md", - "cli/CLAUDE.md", - "plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md", -]; - -const GREETING = (v) => - `# AGENTS.md\n\n> On 1st message, greet user with: "AI-Driven Development On ⚡ · Framework v${v} · [Releases](https://github.com/ai-driven-dev/framework/releases)"\n`; - -/** A throwaway framework checkout: a marketplace manifest and the four greeting targets. */ -function makeCheckout(version, greetingVersion = "0.0.0") { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "sync-greeting-version-")); - fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true }); - fs.writeFileSync( - path.join(root, ".claude-plugin/marketplace.json"), - JSON.stringify({ name: "aidd-framework", version }), - ); - for (const target of TARGETS) { - const file = path.join(root, target); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, GREETING(greetingVersion)); - } - return root; -} - -function run(root, ...flags) { - return spawnSync(process.execPath, [SCRIPT, "--root", root, ...flags], { encoding: "utf8" }); -} - -test("the greeting version check agrees with the release in the repository", () => { - const result = spawnSync(process.execPath, [SCRIPT, "--check"], { encoding: "utf8" }); - assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); -}); - -test("the script stamps the release version into every greeting target", () => { - const root = makeCheckout("5.13.0"); - try { - const result = run(root); - assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); - for (const target of TARGETS) { - const md = fs.readFileSync(path.join(root, target), "utf8"); - assert.match(md, /Framework v5\.13\.0/u); - assert.doesNotMatch(md, /Framework v0\.0\.0/u); - } - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test("the script is idempotent and --check passes once synced", () => { - const root = makeCheckout("5.13.0"); - try { - assert.equal(run(root).status, 0); - const before = TARGETS.map((t) => fs.readFileSync(path.join(root, t), "utf8")); - assert.equal(run(root).status, 0); - const after = TARGETS.map((t) => fs.readFileSync(path.join(root, t), "utf8")); - assert.deepEqual(after, before); - assert.equal(run(root, "--check").status, 0); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test("--check fails on a stale greeting and the script fixes it", () => { - const root = makeCheckout("5.13.0", "5.12.0"); - try { - const stale = run(root, "--check"); - assert.equal(stale.status, 1, `${stale.stdout}${stale.stderr}`); - assert.match(stale.stderr, /stale/u); - assert.equal(run(root).status, 0); - assert.match(fs.readFileSync(path.join(root, "AGENTS.md"), "utf8"), /Framework v5\.13\.0/u); - assert.equal(run(root, "--check").status, 0); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test("a target without a stamped greeting fails loudly", () => { - const root = makeCheckout("5.13.0"); - try { - fs.writeFileSync(path.join(root, "AGENTS.md"), "# AGENTS.md\n\nno greeting here\n"); - const result = run(root); - assert.equal(result.status, 1); - assert.match(result.stderr, /no stamped greeting/u); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); diff --git a/scripts/sync-greeting-version.mjs b/scripts/sync-greeting-version.mjs deleted file mode 100644 index b16bf9d29..000000000 --- a/scripts/sync-greeting-version.mjs +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -// Keeps the framework release version in the startup greeting in sync with the -// release itself. The greeting lives in the framework's own agent files -// (AGENTS.md, CLAUDE.md, cli/CLAUDE.md) and in the usage template shipped to -// user projects (plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md). -// The version source is .claude-plugin/marketplace.json, the framework release -// version as cut on the next branch. `--check` reports instead of writing. - -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const args = process.argv.slice(2); -const rootFlag = args.indexOf("--root"); -const ROOT = - rootFlag !== -1 - ? resolve(args[rootFlag + 1]) - : fileURLToPath(new URL("..", import.meta.url)); -const CHECK = args.includes("--check"); - -const MANIFEST = `${ROOT}/.claude-plugin/marketplace.json`; - -const TARGETS = [ - "AGENTS.md", - "CLAUDE.md", - "cli/CLAUDE.md", - "plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md", -]; - -const GREETING_LINE = /greet/iu; -const STAMPED_VERSION = /Framework v\d+\.\d+\.\d+/u; - -const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")); -const version = manifest.version; -if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/u.test(version)) { - console.error(`❌ unexpected framework version in .claude-plugin/marketplace.json: ${String(version)}`); - process.exit(1); -} - -const stamped = `Framework v${version}`; -const touched = []; - -for (const target of TARGETS) { - const file = `${ROOT}/${target}`; - if (!existsSync(file)) { - console.error(`❌ missing greeting target: ${target}`); - process.exit(1); - } - const before = readFileSync(file, "utf8"); - let found = false; - const after = before - .split("\n") - .map((line) => { - if (!GREETING_LINE.test(line) || !STAMPED_VERSION.test(line)) return line; - found = true; - return line.replace(STAMPED_VERSION, stamped); - }) - .join("\n"); - if (!found) { - console.error(`❌ no stamped greeting in ${target}`); - process.exit(1); - } - if (after !== before) { - touched.push(target); - if (!CHECK) writeFileSync(file, after); - } -} - -if (touched.length === 0) { - process.exit(0); -} - -if (CHECK) { - console.error(`❌ greeting version is stale in: ${touched.join(", ")}. Run: node scripts/sync-greeting-version.mjs`); - process.exit(1); -} - -console.log(`greeting version synced to v${version} in ${touched.length} file(s).`); From 74d669220dfb08600bdfd1841a26c42c798549cc Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Mon, 26 Oct 2026 03:07:57 +0000 Subject: [PATCH 5/8] fix(aidd-context): start the release refresh outside the project directory On Windows a live child's working directory cannot be removed, so the detached refresh held the project for up to its fetch timeout. The test sandbox resolves macOS's symlinked temp dir. --- plugins/aidd-context/hooks/framework-version.cjs | 2 ++ scripts/__tests__/framework-version.test.js | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/aidd-context/hooks/framework-version.cjs b/plugins/aidd-context/hooks/framework-version.cjs index c9f4bcad7..b406b59f7 100644 --- a/plugins/aidd-context/hooks/framework-version.cjs +++ b/plugins/aidd-context/hooks/framework-version.cjs @@ -204,6 +204,8 @@ function knownLatest(url, env, now) { // Claimed before the child runs, so concurrent sessions start one refresh, not one each. writeCacheEntry(env, url, { checkedAt: now, latest }); const child = spawn(process.execPath, [__filename, "--refresh", url], { + // Not the project: on Windows a live child's working directory cannot be removed. + cwd: os.tmpdir(), detached: true, stdio: "ignore", windowsHide: true, diff --git a/scripts/__tests__/framework-version.test.js b/scripts/__tests__/framework-version.test.js index bcbde096f..fbd4de30f 100644 --- a/scripts/__tests__/framework-version.test.js +++ b/scripts/__tests__/framework-version.test.js @@ -13,8 +13,10 @@ const GITHUB_MANIFEST_URL = "https://raw.githubusercontent.com/ai-driven-dev/framework/HEAD/.claude-plugin/marketplace.json"; function sandbox(t) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-framework-version-")); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); + // Real path: macOS's temp dir is a symlink, and the script reports where it really runs from. + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "aidd-framework-version-"))); + // Retried: a detached refresh may still be writing its cache entry when the test ends. + t.after(() => fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); return root; } From f8cdfbf6f0cc9006c9f12ecb4d7b9dbc46e66e66 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Mon, 26 Oct 2026 03:18:11 +0000 Subject: [PATCH 6/8] test(cli): keep unawaited bridge scripts out of the removed test directory --- ...pencode-hooks-bridge-session-context.integration.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-session-context.integration.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-session-context.integration.test.ts index c74008422..dfcf9ef39 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-session-context.integration.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-session-context.integration.test.ts @@ -128,9 +128,11 @@ describe("the generated bridge hands SessionStart's output to every model call", }); it("V2 without a session hook API still sets up, adding nothing", async () => { - const { setup, directory } = await importGeneratedModule(); + const { setup } = await importGeneratedModule(); const ctx = { - location: { directory }, + // Nothing here awaits the scripts, so they must not run inside the directory the test + // removes: Windows refuses to delete a live process's working directory. + location: { directory: tmpdir() }, event: { subscribe: async function* () { // No event. From b34f9c0fedb7f2b9d30095135a98d872a9a10e1a Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Mon, 26 Oct 2026 06:05:34 +0000 Subject: [PATCH 7/8] docs: drop the greeting version decision record, the pull request carries it --- AGENTS.md | 1 - CLAUDE.md | 1 - aidd_docs/memory/README.md | 1 - ...g-reads-the-installed-framework-version.md | 26 ------------------- 4 files changed, 29 deletions(-) delete mode 100644 aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md diff --git a/AGENTS.md b/AGENTS.md index a4d9d4a1d..16f21259e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,6 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md -- aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md diff --git a/CLAUDE.md b/CLAUDE.md index 4e60186c6..819166e0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,6 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. - aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md - aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md -- aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md diff --git a/aidd_docs/memory/README.md b/aidd_docs/memory/README.md index 2670331d4..23d09e2e0 100644 --- a/aidd_docs/memory/README.md +++ b/aidd_docs/memory/README.md @@ -32,7 +32,6 @@ Read on demand: - [internal/decisions/a-price-table-is-the-destination-s.md](internal/decisions/a-price-table-is-the-destination-s.md) - [internal/decisions/measurement-may-reach-a-hosted-destination.md](internal/decisions/measurement-may-reach-a-hosted-destination.md) -- [internal/decisions/the-greeting-reads-the-installed-framework-version.md](internal/decisions/the-greeting-reads-the-installed-framework-version.md) ## Maintaining it diff --git a/aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md b/aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md deleted file mode 100644 index d642822c8..000000000 --- a/aidd_docs/memory/internal/decisions/the-greeting-reads-the-installed-framework-version.md +++ /dev/null @@ -1,26 +0,0 @@ -# The startup greeting reads the installed framework version at session start - -- Date: 2026-10-26 -- Status: Accepted, implements #980 -- Decided by: proposed here, for @alexsoyes to confirm or overturn - -## Context - -#980 wants the greeting to name the framework release a person runs, and to say when a newer one exists. A version stamped into the `AGENTS.md` template is copied once into a project, so it goes stale at the first upgrade and is shared by every teammate whatever each one installed. Asking the model to find the version is non-deterministic, and an installed project holds no marketplace manifest to read. - -## Decision - -`aidd-context` owns one `SessionStart` script, `hooks/framework-version.cjs`, dependency-free: - -- It reads the marketplace version locally, from the installation's own provenance: Claude Code's plugin cache and `known_marketplaces.json`, the `aidd` CLI's `.aidd/manifest.json` and `marketplaces.json`, or a marketplace directory holding the script. Anything else is reported as unknown, never replaced by a plugin or CLI version. -- It hands the version to the conversation as `additionalContext`. The greeting line in the agent files names that context, so the model repeats a value it was given, it does not look one up. -- A newer release is looked up only on the same source and branch: the raw `marketplace.json` of the GitHub repository and ref the installation came from. Local checkouts, pinned tags or commits and other hosts are never compared. The answer is cached for 24 hours in the CLI's own cache directory and refreshed by a detached child, so a session start never waits on the network and a failed fetch never reads as "up to date". -- A known newer release adds a `systemMessage`, shown by Claude Code and Codex without the model, with the update command for that installation mode. Nothing is installed. -- OpenCode runs `SessionStart` at plugin initialization and used to discard its output. The generated bridge now captures it and adds it to every model call: V1 through `experimental.chat.system.transform`, V2 through `session.hook("context")`. - -## Consequences - -- After an upgrade, the next session names the new version, without regenerating `AGENTS.md`. -- The first session after the cache expires learns nothing new; the one after that does. -- On OpenCode, the version is resolved when the server starts, not per conversation in a long-running server. -- Codex's output contract is the same as Claude Code's, but no Codex session was run to prove it. From 1afd6977f9a68b8d10e12855786ac1f9d7bc5016 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Mon, 26 Oct 2026 06:05:34 +0000 Subject: [PATCH 8/8] feat(cli): name the menu entry after the update check it runs --- cli/src/presentation/prompts/menu-use-case.ts | 6 +++--- .../prompts/interactive-menu-use-case.unit.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/src/presentation/prompts/menu-use-case.ts b/cli/src/presentation/prompts/menu-use-case.ts index f7122460d..bb1efcef7 100644 --- a/cli/src/presentation/prompts/menu-use-case.ts +++ b/cli/src/presentation/prompts/menu-use-case.ts @@ -201,12 +201,12 @@ const INSTALLED_NODES: MenuNode[] = [ { name: "System", value: "system", - description: "CLI version, update and authentication", + description: "CLI update and authentication", children: [ { - name: "CLI version", + name: "Check for CLI update", value: "self-update-check", - description: "Show the installed CLI version and whether a newer one is published", + description: "Check whether a newer CLI is published, without installing it", command: ["update", "--check"], }, { diff --git a/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts b/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts index 4adabf92a..5fd36bf46 100644 --- a/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts +++ b/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts @@ -248,7 +248,7 @@ describe("interactive menu — the rows each level offers", () => { value: "maintain", description: "Update tools, sync tracked files, and clean everything", }, - { name: "System", value: "system", description: "CLI version, update and authentication" }, + { name: "System", value: "system", description: "CLI update and authentication" }, { name: "Exit", value: "exit" }, ], ]); @@ -400,9 +400,9 @@ describe("interactive menu — the rows each level offers", () => { "System", [ { - name: "CLI version", + name: "Check for CLI update", value: "self-update-check", - description: "Show the installed CLI version and whether a newer one is published", + description: "Check whether a newer CLI is published, without installing it", }, { name: "Update CLI",