From a705f989dc49b53cdbcfe0a56ff474712da7c7f3 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Wed, 15 Jul 2026 17:11:02 +0700 Subject: [PATCH 01/14] fix(review): clean leftover model IDs, README refs to removed skills, changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #66 (self-review): - video-analysis.md / audio-processing.md still carried gemini-3-pro-preview and gemini-1.5-flash; model sections were also duplicated after the bulk rename — collapsed to a single gemma-4-31b-it recommendation pointing at runtime.json. - Root README and package README still advertised hapo:generate-graph and listed impact-analysis as bundled (package README ships in the npm tarball). - CHANGELOG [Unreleased] and docs/project-changelog.md now document the batch including the breaking skill removal. --- README.md | 1 - docs/project-changelog.md | 6 ++++++ packages/spec/CHANGELOG.md | 15 +++++++++++++++ packages/spec/README.md | 3 +-- .../ai-multimodal/references/audio-processing.md | 4 +--- .../ai-multimodal/references/video-analysis.md | 8 ++------ 6 files changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8c1c98e..8fe9a29 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ CafeKit installs a working runtime bundle for Claude Code: - `hapo:debug` and `hapo:hotfix` for evidence-first diagnosis and safe bug fixes - `hapo:docs` for project documentation and source-backed as-is reconstruction - `hapo:test` and `hapo:code-review` for verification -- `hapo:generate-graph` for technical diagrams - supporting hooks, agents, rules, and statusline runtime CafeKit uses rule-based skill routing guidance and an installed skill catalog. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index b198b0f..129b861 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -5,6 +5,12 @@ All notable changes to CafeKit are documented here, following ## [Unreleased] +### Removed +- `generate-graph` and `impact-analysis` skills (unused) + orphaned `scripts/browser-tool.cjs`; routing/manifest/OpenCode wrappers/self-tests cleaned; upgrades remove them from existing installs (PR #66). + +### Fixed +- specs `--validate` dispatch off-by-one (Steps 1-7, not 1-8); ui-ux-designer agent dev-path → installed `.claude/skills/` venv path; Gemini model IDs unified on `gemma-4-31b-it` family (runtime.json is the single source); installer `--lang` unknown codes fall back to English; installer settings merge dedupes per command and survives malformed settings.json; `validate-docs.cjs` exits 1 on broken links; spec validator verifies every tagged contract block per task (PR #66). + ### Fixed — Hook hardening - **`session.cjs` env escaping**: `writeEnv` now escapes `\ $ \` "` (was `"` only), so an attacker-influenced value flowing into `CLAUDE_ENV_FILE` (e.g. a git branch named `` evil$HOME-x`pwd` ``) can no longer expand or execute when the env file is sourced. Matches the canonical `escapeShellValue` semantics in `lib/config.cjs`. - **`privacy-block.cjs` symlink bypass**: the sensitive-file gate now resolves symlink targets via `fs.realpathSync` and checks both the requested name and its real target, closing the bypass where a harmless-looking symlink (e.g. `notes.txt` → `.env`) slipped past the basename check. Exemptions (`.env.example`) still win on either name; fail-open when the path cannot be resolved. diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 831936a..6477c0f 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed +- **`generate-graph` and `impact-analysis` skills** (unused) plus the orphaned `scripts/browser-tool.cjs`. All routing rules, OpenCode command wrappers, manifest entries, and self-test expectations cleaned; "blast radius / side effects" intent now routes to `hapo:inspect`. Existing installs are cleaned automatically on upgrade via new `obsolete.runtimeFiles` entries. + +### Fixed +- **specs SKILL**: `--validate` dispatch said "MUST NOT execute Steps 1-8" while jumping to Step 8 — corrected to 1-7. +- **ui-ux-designer agent**: `search.py` invocations used the dev-monorepo path; now use the installed `.claude/skills/` path via the skills venv. +- **Model IDs unified on the `gemma-4-31b-it` family** across ai-multimodal, frontend-design, and inspect (SKILL bodies, references, `.env.example`, script defaults). `runtime.json → gemini.model` is the single source of truth; previously three sources disagreed. +- **Installer i18n**: unsupported `--lang` codes now fall back to English (previously Japanese). +- **Installer settings merge**: managed hooks merge per command keyed by matcher entry — a command added to an existing matcher is appended on upgrade (the old dedupe judged the whole entry duplicate by its first command). Malformed user `settings.json` no longer aborts the install; the merge is skipped with a warning. +- **`validate-docs.cjs`** exits 1 on broken relative links (previously always exit 0). +- **`validate-spec-output.cjs`** verifies every `` tagged block in a task, not just the first fenced block. Multi-contract tasks must tag each copy (untagged → error); tagged-but-undeclared blocks warn; single-contract legacy format unchanged. Covered by a new self-test fixture (138 tests total). + +### Changed +- Installer obsolete mechanism removes directories recursively and prunes ownership-manifest entries by prefix (`tracker.prunePrefix`), enabling skill-level cleanup on upgrade. + ## [0.13.2] - 2026-06-21 ### Added — Enforce scaffold on task creation diff --git a/packages/spec/README.md b/packages/spec/README.md index 3f259c4..b9cc23e 100644 --- a/packages/spec/README.md +++ b/packages/spec/README.md @@ -111,9 +111,8 @@ CafeKit ships many skills, but the main release surface is: - `/hapo:docs [--init|--update|--summarize|--reconstruct]`: create project docs or reconstruct as-is system documentation from source code - `/hapo:test [scope|--full]`: run verification and return a structured verdict - `/hapo:code-review [scope|--pending]`: adversarial review focused on correctness, regressions, and security -- `/hapo:generate-graph `: generate technical SVG/PNG diagrams -Common companion skills bundled in this package include `inspect`, `impact-analysis`, `research`, `ai-multimodal`, `frontend-development`, `backend-development`, and `react-best-practices`. +Common companion skills bundled in this package include `inspect`, `research`, `ai-multimodal`, `frontend-development`, `backend-development`, and `react-best-practices`. CafeKit uses rule-based skill routing guidance instead of an automatic prompt-scoring hook. See `.claude/rules/skill-workflow-routing.md`, `.claude/rules/skill-domain-routing.md`, or run: diff --git a/packages/spec/src/claude/skills/ai-multimodal/references/audio-processing.md b/packages/spec/src/claude/skills/ai-multimodal/references/audio-processing.md index aa5dbee..22f7eca 100644 --- a/packages/spec/src/claude/skills/ai-multimodal/references/audio-processing.md +++ b/packages/spec/src/claude/skills/ai-multimodal/references/audio-processing.md @@ -244,9 +244,7 @@ response = client.models.generate_content( - 9.5 hours = 1,094,400 tokens **Model selection**: -- Use `gemma-4-31b-it` ($1/1M tokens) for most tasks -- Upgrade to `gemma-4-31b-it` ($3/1M tokens) for complex analysis -- For high-volume: `gemini-1.5-flash` ($0.70/1M tokens) +- Use `gemma-4-31b-it` for transcription and analysis (read model from `.claude/runtime.json`: `gemini.model`) **Reduce costs**: - Process only relevant segments using timestamps diff --git a/packages/spec/src/claude/skills/ai-multimodal/references/video-analysis.md b/packages/spec/src/claude/skills/ai-multimodal/references/video-analysis.md index ed5e717..4bfc497 100644 --- a/packages/spec/src/claude/skills/ai-multimodal/references/video-analysis.md +++ b/packages/spec/src/claude/skills/ai-multimodal/references/video-analysis.md @@ -22,12 +22,8 @@ Comprehensive guide for video understanding, temporal analysis, and YouTube proc ## Model Selection -### Gemini 3 Series (Latest) -- **gemini-3-pro-preview**: Latest, agentic workflows, 1M context, dynamic thinking - -### Gemini 2.5 Series (Recommended) -- **gemma-4-31b-it**: Best quality, 1M-2M context -- **gemma-4-31b-it**: Balanced, 1M-2M context (recommended) +### Recommended +- **gemma-4-31b-it**: 1M-2M context, balanced quality/speed (read model from `.claude/runtime.json`: `gemini.model`) ### Context Windows - **2M token models**: ~2 hours (default) or ~6 hours (low-res) From 71f6b450b2e1c1f994c62f2fe3729eb0f5610127 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Wed, 15 Jul 2026 17:15:16 +0700 Subject: [PATCH 02/14] fix(test): force colorless child output in test-count parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseNodeTestCount anchors on line-start 'ℹ tests N'; when the child inherits a color-forcing env the ANSI prefix breaks the match and the suite false-fails with NO_TESTS despite all tests passing. Spawn test children with NO_COLOR=1 / FORCE_COLOR=0. --- packages/spec/scripts/run-skill-self-tests.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/spec/scripts/run-skill-self-tests.mjs b/packages/spec/scripts/run-skill-self-tests.mjs index 2329f5e..cbef6d4 100644 --- a/packages/spec/scripts/run-skill-self-tests.mjs +++ b/packages/spec/scripts/run-skill-self-tests.mjs @@ -20,7 +20,9 @@ function runCommand({ label, command, args, parseCount }) { return new Promise((resolveRun) => { const child = spawn(command, args, { cwd: packageRoot, - env: process.env, + // Force colorless child output: the count parsers anchor on line starts, + // and ANSI prefixes (e.g. FORCE_COLOR envs) break them -> false NO_TESTS. + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, stdio: ["ignore", "pipe", "pipe"], }); From cb05ace2c43a15db22a9f49f06afe1fac49101a6 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 10:19:09 +0700 Subject: [PATCH 03/14] fix(skills): credit Anthropic as author of the adapted document skills --- packages/spec/src/claude/skills/docx/SKILL.md | 2 +- packages/spec/src/claude/skills/pdf/SKILL.md | 2 +- packages/spec/src/claude/skills/pptx/SKILL.md | 2 +- packages/spec/src/claude/skills/xlsx/SKILL.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/claude/skills/docx/SKILL.md b/packages/spec/src/claude/skills/docx/SKILL.md index f419c60..280289f 100644 --- a/packages/spec/src/claude/skills/docx/SKILL.md +++ b/packages/spec/src/claude/skills/docx/SKILL.md @@ -7,7 +7,7 @@ when_to_use: "Invoke to read, create, or edit Word .docx documents." category: document-skills keywords: [docx, word, document, office] metadata: - author: haposoft + author: "Anthropic, PBC — adapted by Haposoft" version: "1.0.0" --- # DOCX creation, editing, and analysis diff --git a/packages/spec/src/claude/skills/pdf/SKILL.md b/packages/spec/src/claude/skills/pdf/SKILL.md index 140484b..0b30d7d 100644 --- a/packages/spec/src/claude/skills/pdf/SKILL.md +++ b/packages/spec/src/claude/skills/pdf/SKILL.md @@ -7,7 +7,7 @@ when_to_use: "Invoke to read, extract, or generate PDF documents." category: document-skills keywords: [pdf, extract, document, generate] metadata: - author: haposoft + author: "Anthropic, PBC — adapted by Haposoft" version: "1.0.0" --- # PDF Processing Guide diff --git a/packages/spec/src/claude/skills/pptx/SKILL.md b/packages/spec/src/claude/skills/pptx/SKILL.md index e785d15..d4e3069 100644 --- a/packages/spec/src/claude/skills/pptx/SKILL.md +++ b/packages/spec/src/claude/skills/pptx/SKILL.md @@ -7,7 +7,7 @@ when_to_use: "Invoke to read, create, or edit PowerPoint .pptx slides." category: document-skills keywords: [pptx, powerpoint, slides, office] metadata: - author: haposoft + author: "Anthropic, PBC — adapted by Haposoft" version: "1.0.0" --- # PPTX creation, editing, and analysis diff --git a/packages/spec/src/claude/skills/xlsx/SKILL.md b/packages/spec/src/claude/skills/xlsx/SKILL.md index 332bae0..67d9933 100644 --- a/packages/spec/src/claude/skills/xlsx/SKILL.md +++ b/packages/spec/src/claude/skills/xlsx/SKILL.md @@ -7,7 +7,7 @@ when_to_use: "Invoke to read, create, or edit Excel .xlsx spreadsheets." category: document-skills keywords: [xlsx, excel, spreadsheet, office] metadata: - author: haposoft + author: "Anthropic, PBC — adapted by Haposoft" version: "1.0.0" --- # Requirements for Outputs From f963bff2f34a1a1669ef7923a75f0afd5530ac4c Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 10:19:09 +0700 Subject: [PATCH 04/14] fix(opencode): compact banner asks the user directly instead of AskUserQuestion --- packages/spec/src/opencode/plugins/session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/opencode/plugins/session.ts b/packages/spec/src/opencode/plugins/session.ts index 6cddadd..7aa67e5 100644 --- a/packages/spec/src/opencode/plugins/session.ts +++ b/packages/spec/src/opencode/plugins/session.ts @@ -153,9 +153,9 @@ function handleSessionCreated(cwd: string): void { function handleSessionCompacted(cwd: string): void { const lines = [ "🚨 SESSION COMPRESSED — VERIFY PENDING AUTHORIZATIONS:", - "Any pending confirmations requested via AskUserQuestion might have been lost.", + "Any pending confirmations you previously requested might have been lost.", "Do not proceed without explicitly asking the user again to ensure safety.", - 'Use AskUserQuestion: "The chat context was compressed. Do I still have permission to proceed?"', + 'Ask the user directly in chat: "The chat context was compressed. Do I still have permission to proceed?"', ]; writeBannerSection(cwd, lines.join("\n")); console.error(lines.join("\n")); From 937fa5cd892b222c26371ee9e9668383a1b2da51 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 10:19:09 +0700 Subject: [PATCH 05/14] fix(skills): point frontend-design license at MIT instead of missing LICENSE.txt --- packages/spec/src/claude/skills/frontend-design/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/claude/skills/frontend-design/SKILL.md b/packages/spec/src/claude/skills/frontend-design/SKILL.md index 0596841..7346aa9 100644 --- a/packages/spec/src/claude/skills/frontend-design/SKILL.md +++ b/packages/spec/src/claude/skills/frontend-design/SKILL.md @@ -1,7 +1,7 @@ --- name: hapo:frontend-design description: Create polished frontend interfaces from designs/screenshots/videos. Use for web components, 3D experiences, replicating UI designs, quick prototypes, immersive interfaces, avoiding AI slop. -license: Complete terms in LICENSE.txt +license: MIT user-invocable: true when_to_use: "Invoke to translate mockups or screenshots into UI implementation." category: frontend From 6dfdb656213daead82e41090755cdc554749a9a6 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 10:19:09 +0700 Subject: [PATCH 06/14] docs(changelog): record audit batch 1.5 fixes --- docs/project-changelog.md | 1 + packages/spec/CHANGELOG.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 129b861..4165919 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -10,6 +10,7 @@ All notable changes to CafeKit are documented here, following ### Fixed - specs `--validate` dispatch off-by-one (Steps 1-7, not 1-8); ui-ux-designer agent dev-path → installed `.claude/skills/` venv path; Gemini model IDs unified on `gemma-4-31b-it` family (runtime.json is the single source); installer `--lang` unknown codes fall back to English; installer settings merge dedupes per command and survives malformed settings.json; `validate-docs.cjs` exits 1 on broken links; spec validator verifies every tagged contract block per task (PR #66). +- Document-skill attribution restored to Anthropic (`pdf`/`pptx`/`docx`/`xlsx` frontmatter author); OpenCode compact banner no longer instructs the unavailable `AskUserQuestion` tool — asks the user directly; `frontend-design` license line corrected to MIT, removing a dangling `LICENSE.txt` reference (PR #67). ### Fixed — Hook hardening - **`session.cjs` env escaping**: `writeEnv` now escapes `\ $ \` "` (was `"` only), so an attacker-influenced value flowing into `CLAUDE_ENV_FILE` (e.g. a git branch named `` evil$HOME-x`pwd` ``) can no longer expand or execute when the env file is sourced. Matches the canonical `escapeShellValue` semantics in `lib/config.cjs`. diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 6477c0f..6cbdfdc 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Installer settings merge**: managed hooks merge per command keyed by matcher entry — a command added to an existing matcher is appended on upgrade (the old dedupe judged the whole entry duplicate by its first command). Malformed user `settings.json` no longer aborts the install; the merge is skipped with a warning. - **`validate-docs.cjs`** exits 1 on broken relative links (previously always exit 0). - **`validate-spec-output.cjs`** verifies every `` tagged block in a task, not just the first fenced block. Multi-contract tasks must tag each copy (untagged → error); tagged-but-undeclared blocks warn; single-contract legacy format unchanged. Covered by a new self-test fixture (138 tests total). +- **Document skills attribution**: `pdf`/`pptx`/`docx`/`xlsx` frontmatter `metadata.author` now credits `Anthropic, PBC — adapted by Haposoft` (the bundled `LICENSE.txt` files are Anthropic's; the field previously claimed haposoft). +- **OpenCode session plugin**: the compact-recovery banner instructed the Claude-only `AskUserQuestion` tool, which `AGENTS.md` declares unavailable in OpenCode — it now tells the agent to ask the user directly in chat. +- **frontend-design skill**: frontmatter pointed at a non-existent `LICENSE.txt`; corrected to `license: MIT` (no Anthropic-derived content found in the skill folder). ### Changed - Installer obsolete mechanism removes directories recursively and prunes ownership-manifest entries by prefix (`tracker.prunePrefix`), enabling skill-level cleanup on upgrade. From dc74f1ec6b9a3f383daa2db12dff0e39260c4ee7 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 11:07:57 +0700 Subject: [PATCH 07/14] feat(opencode): port spec-state tollgate and task-scaffold-guard plugins (Option A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the spec-workflow enforcement gap on OpenCode. Both plugins were independently verified with live smoke tests on opencode 1.17.15: - spec-state.ts injects the tollgate via chat.message. Parts pushed onto output.parts MUST be schema-complete (id/sessionID/messageID) — the original bare {type,text} part failed OpenCode's durable-part schema and crashed every user turn ('invalid user part before save'); fixed and verified end-to-end (tollgate text reaches the model). - task-scaffold-guard.ts blocks write/apply_patch on task files, and additionally gates edit on non-existent task files: OpenCode's edit can CREATE files (unlike Claude's Edit), a bypass proven live. Edit on an existing stub stays allowed. Existence checks resolve the specs/ suffix against the plugin directory (tool paths may be sandbox-virtual). - Block message no longer advertises the runtime.json escape hatch: the smoke test caught the model writing scaffold_guard:false itself and disabling the guard. The hatch stays functional for humans. - session.ts compaction banner + AGENTS.md now map Claude-only tools to OpenCode built-ins (AskUserQuestion->question, TodoWrite->todowrite, Task->agent/subtask). - agent.cjs remains unported (documented: low ROI, unreliable surface). - Port audit + plugin contract docs updated with the verified findings. --- docs/project-changelog.md | 6 +- packages/spec/CHANGELOG.md | 6 + .../spec/docs/opencode-hook-port-audit.md | 34 ++- .../spec/docs/opencode-plugin-contract.md | 2 +- .../spec/scripts/run-skill-self-tests.mjs | 2 + packages/spec/src/opencode/AGENTS.md | 2 +- packages/spec/src/opencode/plugins/session.ts | 4 +- .../spec/src/opencode/plugins/spec-state.ts | 244 ++++++++++++++++++ .../opencode/plugins/task-scaffold-guard.ts | 161 ++++++++++++ 9 files changed, 445 insertions(+), 16 deletions(-) create mode 100644 packages/spec/src/opencode/plugins/spec-state.ts create mode 100644 packages/spec/src/opencode/plugins/task-scaffold-guard.ts diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 4165919..2c82203 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -5,12 +5,16 @@ All notable changes to CafeKit are documented here, following ## [Unreleased] +### Added +- **OpenCode port Option A — spec-workflow enforcement**: `task-scaffold-guard.ts` (block hand-written task files via `write`/`apply_patch`) and `spec-state.ts` (tollgate inject via `chat.message`); installer self-test expects both plugins. + ### Removed - `generate-graph` and `impact-analysis` skills (unused) + orphaned `scripts/browser-tool.cjs`; routing/manifest/OpenCode wrappers/self-tests cleaned; upgrades remove them from existing installs (PR #66). ### Fixed - specs `--validate` dispatch off-by-one (Steps 1-7, not 1-8); ui-ux-designer agent dev-path → installed `.claude/skills/` venv path; Gemini model IDs unified on `gemma-4-31b-it` family (runtime.json is the single source); installer `--lang` unknown codes fall back to English; installer settings merge dedupes per command and survives malformed settings.json; `validate-docs.cjs` exits 1 on broken links; spec validator verifies every tagged contract block per task (PR #66). -- Document-skill attribution restored to Anthropic (`pdf`/`pptx`/`docx`/`xlsx` frontmatter author); OpenCode compact banner no longer instructs the unavailable `AskUserQuestion` tool — asks the user directly; `frontend-design` license line corrected to MIT, removing a dangling `LICENSE.txt` reference (PR #67). +- Document-skill attribution restored to Anthropic (`pdf`/`pptx`/`docx`/`xlsx` frontmatter author); `frontend-design` license line corrected to MIT, removing a dangling `LICENSE.txt` reference (PR #67). +- OpenCode `session.ts` compaction banner + `AGENTS.md` map Claude `AskUserQuestion`/`TodoWrite`/`Task` to OpenCode built-ins (`question`/`todowrite`/agent-subtask) instead of instructing unavailable tools. ### Fixed — Hook hardening - **`session.cjs` env escaping**: `writeEnv` now escapes `\ $ \` "` (was `"` only), so an attacker-influenced value flowing into `CLAUDE_ENV_FILE` (e.g. a git branch named `` evil$HOME-x`pwd` ``) can no longer expand or execute when the env file is sourced. Matches the canonical `escapeShellValue` semantics in `lib/config.cjs`. diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 6cbdfdc..9ee088d 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -7,10 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **OpenCode `task-scaffold-guard.ts` plugin**: ports Claude `task-scaffold-guard.cjs` — hard-blocks `write` and `apply_patch` creating `specs//tasks/task-*.md`, forcing generation via `spec-scaffold.cjs` then Edit-fill. Also gates `edit` on a **non-existent** task file: OpenCode's `edit` can create files (unlike Claude's Edit — smoke-verified on opencode 1.17.15), so edit-creation is blocked while stub-filling stays allowed. Safety valves: runtime escape hatch (functional but **not advertised in the block message** — a smoke test proved the model reads the advertised override and disables the guard itself), fail-open if the scaffold script is missing, actionable block message. Auto-discovered by the OpenCode installer. +- **OpenCode `spec-state.ts` plugin**: ports Claude `spec-state.cjs` tollgate via `chat.message` — injects state-sync reminder (fingerprint gate: one-line when unchanged, full URGENT block when phase/done-total changes). Injected parts are schema-complete (`id`/`sessionID`/`messageID`) — a bare `{type,text}` part crashes the whole user turn ("invalid user part before save"). Disable with `"spec": { "tollgate": false }` in `.opencode/runtime.json`. End-to-end verified on opencode 1.17.15 (tollgate text reaches the model). + ### Removed - **`generate-graph` and `impact-analysis` skills** (unused) plus the orphaned `scripts/browser-tool.cjs`. All routing rules, OpenCode command wrappers, manifest entries, and self-test expectations cleaned; "blast radius / side effects" intent now routes to `hapo:inspect`. Existing installs are cleaned automatically on upgrade via new `obsolete.runtimeFiles` entries. ### Fixed +- **Claude `task-scaffold-guard.cjs`**: block message no longer advertises the `runtime.json` override (same self-disarm vector proven in the OpenCode smoke test — the model flips the flag instead of scaffolding). The escape hatch remains functional for humans. +- **OpenCode compaction banner / AGENTS.md**: no longer instruct the unavailable Claude `AskUserQuestion` tool; map to OpenCode built-in `question` (and `TodoWrite` → `todowrite`, `Task` → agent/subtask flow). - **specs SKILL**: `--validate` dispatch said "MUST NOT execute Steps 1-8" while jumping to Step 8 — corrected to 1-7. - **ui-ux-designer agent**: `search.py` invocations used the dev-monorepo path; now use the installed `.claude/skills/` path via the skills venv. - **Model IDs unified on the `gemma-4-31b-it` family** across ai-multimodal, frontend-design, and inspect (SKILL bodies, references, `.env.example`, script defaults). `runtime.json → gemini.model` is the single source of truth; previously three sources disagreed. diff --git a/packages/spec/docs/opencode-hook-port-audit.md b/packages/spec/docs/opencode-hook-port-audit.md index 61ca2dd..b25620b 100644 --- a/packages/spec/docs/opencode-hook-port-audit.md +++ b/packages/spec/docs/opencode-hook-port-audit.md @@ -11,9 +11,10 @@ Per-hook decision matrix with exact behavior translation, dependencies on shared | `state.cjs` | SessionStart, PostToolUse, Stop, SubagentStop | `event` + `tool.execute.after` | **PORT (partial)** — drop SubagentStop branch; Stop ≈ session.idle | T3.1 | | `docs-sync.cjs` | SessionStart | `event` (session.created) | **PORT (full)** | T3.2 | | `session.cjs` | SessionStart(startup,resume,clear,compact) | `event` (session.created + session.compacted) | **PORT (partial)** — banner only, no `CLAUDE_ENV_FILE` writes (OpenCode has no equivalent) | T3.3 | -| `spec-state.cjs` | UserPromptSubmit | none | **DROP** — fold into AGENTS.md + specs skill | +| `spec-state.cjs` | UserPromptSubmit | `chat.message` (mutate `output.parts`) | **PORTED** — inject tollgate text part before model turn | +| `task-scaffold-guard.cjs` | PreToolUse(Write) | `tool.execute.before` (`write` + `apply_patch`) | **PORTED** — block task-file creation; three safety valves | | `rules.cjs` | UserPromptSubmit | none | **DROP** — fold into AGENTS.md + skill-workflow-routing.md | -| `agent.cjs` | SubagentStart | none | **DROP** — orchestrator already injects context in subagent prompts | +| `agent.cjs` | SubagentStart | none | **DROPPED** — OpenCode `session.created`+`parentID` subagent surface is unreliable; orchestrator already mandates self-contained subagent prompts (low ROI) | | `usage.cjs` | UserPromptSubmit + PostToolUse | `tool.execute.after` (only) | **PORT (partial)** — drop prompt half; keep file-edit counter | ## Detailed Per-Hook Translation @@ -88,23 +89,34 @@ Per-hook decision matrix with exact behavior translation, dependencies on shared - Compact warning: trigger via `session.compacted` event branch. - `source` field: OpenCode events do not carry source discriminator → all session.created becomes generic "Session started.". -### 6. spec-state.cjs → **DROP** +### 6. spec-state.cjs → `.opencode/plugins/spec-state.ts` (**PORTED**) -**Reason:** Relies on UserPromptSubmit to inject reminders before assistant processes user message. OpenCode has no preempt-style user-prompt hook. The closest `chat.message` fires after submission with no way to prepend system context. +**Rationale (2026-07 re-investigation):** OpenCode `@opencode-ai/plugin` `chat.message` fires **before** the model processes the user message. Mutating `output.parts` (push a text part) injects tollgate context into the turn — the OpenCode equivalent of Claude `UserPromptSubmit` stdout injection. -**Workaround:** Add explicit reminder block to: -- `AGENTS.md` under "Spec workflow guardrails" section -- `hapo:specs` skill front matter +**OpenCode mapping:** +- Hook: `chat.message(input, output)` → push a **schema-complete** part onto `output.parts`: `{ id, sessionID: output.message.sessionID, messageID: output.message.id, type: "text", text, synthetic: true }`. A bare `{ type, text }` part fails OpenCode's durable-part schema and **crashes the entire user turn** ("invalid user part before save" — verified on 1.17.15). +- Scan `//*/spec.json` for first `status === "in_progress"` (also accept legacy `"in-progress"`) +- State-change gate: fingerprint `phase|done/total` in `plugins/.logs/tollgate-last.txt`; unchanged → one-line reminder; changed → full URGENT block (Vietnamese text preserved) + refresh fingerprint +- Escape hatch: `.opencode/runtime.json` `{ "spec": { "tollgate": false } }` (default ON) +- Fail-open crash wrapper → `plugins/.logs/hook-log.jsonl`; never throw + +### 6b. task-scaffold-guard.cjs → `.opencode/plugins/task-scaffold-guard.ts` (**PORTED**) -Document in CHANGELOG/install README that OpenCode users get the spec-drift reminder via AGENTS.md prose only. +**OpenCode mapping:** +- Hook: `tool.execute.before` +- Gate tools: `write` (`output.args.filePath`), `apply_patch` (scan `output.args.patchText` for `*** Add File: ` lines — absent from the 1.17.15 default toolset, kept as a defensive branch), and `edit` on a **non-existent** task file — OpenCode's `edit` CAN create files (unlike Claude's Edit, which requires an existing file), verified on 1.17.15; edit on an existing stub stays allowed (legitimate stub filling). The existence check resolves the project-relative `specs/...` suffix against the plugin `directory` because tool paths may be sandbox-virtual (`/home/user/...`). +- Task-file regex: `/(^|\/)specs\/[^/]+\/tasks\/task-[^/]+\.md$/` +- Three safety valves preserved: runtime `spec.scaffold_guard === false` escape (fail-closed on missing runtime); fail-open if `.opencode/scripts/spec-scaffold.cjs` absent; actionable block message with exact scaffold command +- **Escape hatch is NOT advertised in the block message.** Smoke-tested: when the message included the `runtime.json` override line, the model wrote `{"spec":{"scaffold_guard":false}}` itself and disabled the guard (self-disarm). The hatch stays functional for humans and is documented here only. The Claude `.cjs` hook was hardened identically. +- Crash-wrapper fail-open: re-throw only intentional `TASK SCAFFOLD REQUIRED` errors ### 7. rules.cjs → **DROP** -**Reason:** Same UserPromptSubmit dependency. OpenCode users rely on `AGENTS.md` + `.opencode/rules/skill-workflow-routing.md` + `.opencode/rules/skill-domain-routing.md` — already installed. +**Reason:** Same UserPromptSubmit dependency historically. OpenCode users rely on `AGENTS.md` + `.opencode/rules/skill-workflow-routing.md` + `.opencode/rules/skill-domain-routing.md` — already installed. (Not re-ported; AGENTS fold-in remains sufficient.) -### 8. agent.cjs → **DROP** +### 8. agent.cjs → **DROPPED** (explicit, low ROI) -**Reason:** OpenCode has no `SubagentStart` event. CafeKit orchestrator rule (`orchestrator.md`) already mandates self-contained subagent prompts including work-context paths. No regression. +**Reason:** OpenCode's subagent event surface (`session.created` + `parentID`) is unreliable for seeding subagent context. CafeKit orchestrator rule (`orchestrator.md`) already mandates self-contained subagent prompts including work-context paths. Porting would add fragile coupling for little gain. ### 9. usage.cjs → `.opencode/plugins/usage.ts` (partial) diff --git a/packages/spec/docs/opencode-plugin-contract.md b/packages/spec/docs/opencode-plugin-contract.md index 4b76d8c..e9be676 100644 --- a/packages/spec/docs/opencode-plugin-contract.md +++ b/packages/spec/docs/opencode-plugin-contract.md @@ -169,7 +169,7 @@ export const MyPlugin: Plugin = async () => ({ |---|---|---| | `PreToolUse` | `tool.execute.before` (throw to block, mutate args) | Tool names differ — see normalization below | | `PostToolUse` | `tool.execute.after` | Has full `args` + result | -| `UserPromptSubmit` | **none** | Closest: `chat.message` mutate, but fires after submission; no preemptive gate | +| `UserPromptSubmit` | `chat.message` (mutate `output.parts`) | Fires before model processing (verified on 1.17.15); pushed parts MUST be schema-complete — `id`, `sessionID`, `messageID`, `type`, `text` — or the WHOLE turn fails with "invalid user part before save" (used by `spec-state.ts`) | | `SessionStart` (startup/resume/clear) | `event` with `type === "session.created"` | No `source` discriminator; treat as single mode | | `Stop` | `event` with `type === "session.idle"` | Fires on idle, not exactly Stop semantics | | `SubagentStart` | **none** | No subagent lifecycle exposed | diff --git a/packages/spec/scripts/run-skill-self-tests.mjs b/packages/spec/scripts/run-skill-self-tests.mjs index cbef6d4..f5ca32b 100644 --- a/packages/spec/scripts/run-skill-self-tests.mjs +++ b/packages/spec/scripts/run-skill-self-tests.mjs @@ -1064,6 +1064,8 @@ async function runOpenCodeInstallerFixtureTests() { "state.ts", "usage.ts", "rules.ts", + "task-scaffold-guard.ts", + "spec-state.ts", ]; for (const plugin of expectedPlugins) { if (!(await fileExists(join(root, ".opencode", "plugins", plugin)))) { diff --git a/packages/spec/src/opencode/AGENTS.md b/packages/spec/src/opencode/AGENTS.md index 95c0db0..352d3d6 100644 --- a/packages/spec/src/opencode/AGENTS.md +++ b/packages/spec/src/opencode/AGENTS.md @@ -81,7 +81,7 @@ Use this loop for non-trivial work: - Claude Code hooks/statusline/settings do not run in OpenCode. - Treat `.claude/rules/*` as explicit reference material, not injected hook context. -- When a skill says to use unavailable Claude-specific tools such as `Task`, `TodoWrite`, or `AskUserQuestion`, use OpenCode's agent/subtask flow, a concise markdown checklist, or ask the user directly. +- Map Claude-specific tools to OpenCode built-ins: `TodoWrite` → the `todowrite` tool; `AskUserQuestion` → the `question` tool; `Task` → OpenCode's agent/subtask flow. - Privacy and inspect gates are instruction-level gates in OpenCode unless a project-specific OpenCode plugin adds hard enforcement. ## Operating Discipline diff --git a/packages/spec/src/opencode/plugins/session.ts b/packages/spec/src/opencode/plugins/session.ts index 7aa67e5..368eb95 100644 --- a/packages/spec/src/opencode/plugins/session.ts +++ b/packages/spec/src/opencode/plugins/session.ts @@ -153,9 +153,9 @@ function handleSessionCreated(cwd: string): void { function handleSessionCompacted(cwd: string): void { const lines = [ "🚨 SESSION COMPRESSED — VERIFY PENDING AUTHORIZATIONS:", - "Any pending confirmations you previously requested might have been lost.", + "Any pending confirmations requested via the question tool might have been lost.", "Do not proceed without explicitly asking the user again to ensure safety.", - 'Ask the user directly in chat: "The chat context was compressed. Do I still have permission to proceed?"', + 'Use the question tool: "The chat context was compressed. Do I still have permission to proceed?"', ]; writeBannerSection(cwd, lines.join("\n")); console.error(lines.join("\n")); diff --git a/packages/spec/src/opencode/plugins/spec-state.ts b/packages/spec/src/opencode/plugins/spec-state.ts new file mode 100644 index 0000000..e07f3df --- /dev/null +++ b/packages/spec/src/opencode/plugins/spec-state.ts @@ -0,0 +1,244 @@ +/** + * Copyright (c) 2026 Haposoft. MIT License. + * + * OpenCode Plugin — spec-state.ts + * + * Port of .claude/hooks/spec-state.cjs to OpenCode plugin protocol. + * + * Behavior: + * - On chat.message (before the model processes the user turn), scan + * project specs dirs for the first in_progress / in-progress spec.json. + * - Inject a tollgate reminder into the turn by pushing a text part onto + * output.parts (OpenCode equivalent of Claude UserPromptSubmit stdout inject). + * - State-change gate: fingerprint phase|done/total in plugins/.logs/tollgate-last.txt; + * unchanged -> one-line reminder; changed -> full URGENT block + refresh fingerprint. + * + * Disable via `.opencode/runtime.json` { "spec": { "tollgate": false } }. + * Fail-open: never throw — a tollgate crash must not break the turn. + */ + +import type { Plugin } from "@opencode-ai/plugin"; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + appendFileSync, + readdirSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)); +const CACHE_FILE = join(PLUGIN_DIR, ".logs", "tollgate-last.txt"); + +type SpecJson = { + status?: string; + current_phase?: string; + phase?: string; + task_registry?: Record< + string, + { + status?: string; + dependencies?: string[]; + } + >; +}; + +function readRuntime(cwd: string): Record { + try { + const file = join(cwd, ".opencode", "runtime.json"); + return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}; + } catch { + return {}; + } +} + +function logCrash(error: unknown): void { + try { + const logDir = join(PLUGIN_DIR, ".logs"); + if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }); + appendFileSync( + join(logDir, "hook-log.jsonl"), + JSON.stringify({ + ts: new Date().toISOString(), + hook: "spec-state", + status: "crash", + error: error instanceof Error ? error.message : String(error), + }) + "\n", + ); + } catch { + // fail-open + } +} + +function findActiveSpec( + specsPath: string, +): { featureName: string; activeSpec: SpecJson } | null { + if (!existsSync(specsPath)) return null; + + let entries; + try { + entries = readdirSync(specsPath, { withFileTypes: true }); + } catch { + return null; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const specFile = join(specsPath, entry.name, "spec.json"); + if (!existsSync(specFile)) continue; + try { + const specData = JSON.parse(readFileSync(specFile, "utf8")) as SpecJson; + if (specData.status === "in_progress" || specData.status === "in-progress") { + return { featureName: entry.name, activeSpec: specData }; + } + } catch { + // skip bad JSON + } + } + return null; +} + +function buildTollgateText( + featureName: string, + activeSpec: SpecJson, +): { stateKey: string; fullBlock: string; reminder: string } { + const phase = activeSpec.current_phase || activeSpec.phase || "unknown"; + const taskRegistry = activeSpec.task_registry || {}; + const taskEntries = Object.entries(taskRegistry); + const taskCounts = taskEntries.reduce>((acc, [, task]) => { + const status = task?.status || "pending"; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, {}); + const taskStatusByPath = new Map( + taskEntries.map(([taskPath, task]) => [taskPath, task?.status || "pending"]), + ); + const nextUnblocked = taskEntries.find(([, task]) => { + const status = task?.status || "pending"; + const deps = Array.isArray(task?.dependencies) ? task.dependencies : []; + return status === "pending" && deps.every((dep) => taskStatusByPath.get(dep) === "done"); + }); + + const done = taskCounts.done || 0; + const total = taskEntries.length; + const stateKey = `${phase}|${done}/${total}`; + + const reminder = + `\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${done}/${total} tasks done). ` + + "Tollgate active — sync `spec.json` when state changes.\n"; + + const lines: string[] = [ + "", + "### 🔴 URGENT SYSTEM TOLLGATE (STATE SYNC) 🔴", + `- **Active Feature:** \`${featureName}\``, + `- **Current Phase:** \`${phase}\``, + ]; + if (total > 0) { + lines.push( + `- **Task Registry:** \`${total} total | ${done} done | ${taskCounts.in_progress || 0} in_progress | ${taskCounts.blocked || 0} blocked | ${taskCounts.pending || 0} pending\``, + ); + if (nextUnblocked) { + lines.push(`- **Next Unblocked Task:** \`${nextUnblocked[0]}\``); + } + } + lines.push(""); + lines.push( + "> BẮT BUỘC (MANDATORY): Nếu bạn vừa hoàn thành một bước, bạn KHÔNG ĐƯỢC báo cáo \"Đã xong\" ngay.", + ); + lines.push( + "> Bạn PHẢI sử dụng công cụ Edit để cập nhật trạng thái vật lý sau khi đã có bằng chứng verify thật (build/test/runtime/artifact), không phải chỉ vì code đã viết xong.", + ); + lines.push( + "> 1. Sửa file `spec.json` (status, phase/current_phase, timestamps, `task_files`, `task_registry`, validation state nếu có thay đổi).", + ); + lines.push( + "> 2. Chỉ khi verify xong mới sửa file `tasks/task-*.md` (status + tick '[x]' các sub-task và completion criteria liên quan).", + ); + lines.push( + `> 3. Trước khi set \`ready_for_implementation = true\`, PHẢI chạy \`node .opencode/scripts/validate-spec-output.cjs specs/${featureName}\` và sửa mọi lỗi.`, + ); + lines.push( + "> 4. NẾU VỪA HOÀN THÀNH 1 TASK CÓ SỬA SOURCE CODE, BẮT BUỘC cập nhật ngay tài liệu trong `docs/` (`system-architecture.md` hoặc Changelog) cho đồng bộ.", + ); + lines.push( + "> CẤM VI PHẠM LUẬT TOLLGATE NÀY NHẰM ĐẢM BẢO TÍNH ĐỒNG BỘ CỦA HỆ THỐNG.", + ); + lines.push(""); + + return { stateKey, fullBlock: lines.join("\n"), reminder }; +} + +type MessageRef = { id?: string; sessionID?: string }; + +function injectText( + output: { message?: MessageRef; parts?: unknown[] }, + text: string, +): void { + // OpenCode persists user parts and validates their schema: a part MUST carry + // id + sessionID + messageID or the whole turn fails with + // "invalid user part before save" (verified on opencode 1.17.15). + const message = output.message ?? {}; + if (!Array.isArray(output.parts)) { + (output as { parts: unknown[] }).parts = []; + } + output.parts!.push({ + id: `prt_ck${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`, + sessionID: message.sessionID ?? "", + messageID: message.id ?? "", + type: "text", + text, + synthetic: true, + }); +} + +export const SpecState: Plugin = async ({ directory }) => ({ + "chat.message": async (_input, output) => { + try { + const runtime = readRuntime(directory); + const spec = runtime.spec as Record | undefined; + if (spec?.tollgate === false) return; + + const paths = runtime.paths as Record | undefined; + const specsRel = + typeof paths?.specs === "string" && paths.specs.trim() + ? paths.specs.trim() + : "specs"; + const specsPath = join(directory, specsRel); + + const found = findActiveSpec(specsPath); + if (!found) return; + + const { featureName, activeSpec } = found; + const { stateKey, fullBlock, reminder } = buildTollgateText( + featureName, + activeSpec, + ); + + let lastKey = ""; + try { + lastKey = readFileSync(CACHE_FILE, "utf8").trim(); + } catch { + // first run + } + + if (lastKey === stateKey) { + injectText(output as { message?: MessageRef; parts?: unknown[] }, reminder); + return; + } + + try { + mkdirSync(dirname(CACHE_FILE), { recursive: true }); + writeFileSync(CACHE_FILE, stateKey); + } catch { + // fail-open: still inject full block + } + + injectText(output as { message?: MessageRef; parts?: unknown[] }, fullBlock); + } catch (error) { + logCrash(error); + // never throw — tollgate must not break the turn + } + }, +}); diff --git a/packages/spec/src/opencode/plugins/task-scaffold-guard.ts b/packages/spec/src/opencode/plugins/task-scaffold-guard.ts new file mode 100644 index 0000000..1bcd0dc --- /dev/null +++ b/packages/spec/src/opencode/plugins/task-scaffold-guard.ts @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2026 Haposoft. MIT License. + * + * OpenCode Plugin — task-scaffold-guard.ts + * + * Port of .claude/hooks/task-scaffold-guard.cjs to OpenCode plugin protocol. + * Also covers the OpenCode-only `apply_patch` file-creation vector + * (`*** Add File: ` in `output.args.patchText`). + * + * Behavior: + * - Block `write` and `apply_patch` that create `specs//tasks/task-*.md`. + * - Task files must be generated via `spec-scaffold.cjs`, then Edit-filled. + * + * Safety valves: + * 1. Escape hatch: `.opencode/runtime.json` `{ "spec": { "scaffold_guard": false } }` + * (fail-closed: missing/broken runtime keeps the guard ON). + * 2. Fail-open if `.opencode/scripts/spec-scaffold.cjs` is absent. + * 3. Actionable block message with the exact scaffold command. + */ + +import type { Plugin } from "@opencode-ai/plugin"; +import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const TASK_FILE_RE = /(^|\/)specs\/[^/]+\/tasks\/task-[^/]+\.md$/; +const TASK_FILE_REL_RE = /(?:^|\/)(specs\/[^/]+\/tasks\/task-[^/]+\.md)$/; +const FEATURE_RE = /(^|\/)specs\/([^/]+)\/tasks\//; +const ADD_FILE_RE = /^\*\*\* Add File:\s*(.+?)\s*$/gm; + +const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)); + +function readRuntime(cwd: string): Record { + try { + const file = join(cwd, ".opencode", "runtime.json"); + return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}; + } catch { + return {}; + } +} + +function isTaskFile(filePath: string): boolean { + const norm = filePath.replace(/\\/g, "/"); + return TASK_FILE_RE.test(norm); +} + +function extractFeature(filePath: string): string { + const norm = filePath.replace(/\\/g, "/"); + const m = norm.match(FEATURE_RE); + return m ? m[2] : ""; +} + +function pathsFromApplyPatch(patchText: string): string[] { + const paths: string[] = []; + let match: RegExpExecArray | null; + const re = new RegExp(ADD_FILE_RE.source, ADD_FILE_RE.flags); + while ((match = re.exec(patchText)) !== null) { + const p = match[1]?.trim(); + if (p) paths.push(p); + } + return paths; +} + +function collectTargetPaths(tool: string, args: Record): string[] { + if (tool === "write") { + const filePath = typeof args.filePath === "string" ? args.filePath.trim() : ""; + return filePath ? [filePath] : []; + } + if (tool === "apply_patch") { + const patchText = typeof args.patchText === "string" ? args.patchText : ""; + return patchText ? pathsFromApplyPatch(patchText) : []; + } + return []; +} + +/** + * Resolve a (possibly sandbox-virtual) tool path to the real on-disk task file. + * OpenCode may present paths like /home/user/specs/... to the model; the + * project-relative `specs/...` suffix joined onto the plugin `directory` is + * the reliable location for existence checks. + */ +function resolveTaskFile(directory: string, filePath: string): string { + const norm = filePath.replace(/\\/g, "/"); + const m = norm.match(TASK_FILE_REL_RE); + return m ? join(directory, m[1]) : filePath; +} + +function formatBlockMessage(filePath: string): string { + const feature = extractFeature(filePath); + // NOTE: deliberately does NOT mention the runtime.json escape hatch. + // Smoke-tested on opencode 1.17.15: when the block message advertised the + // override, the model simply wrote runtime.json and disabled the guard. + // The hatch stays functional for humans; it is documented in the docs only. + return [ + "TASK SCAFFOLD REQUIRED: task files must be generated, not hand-written.", + `Blocked Write: ${filePath}`, + "", + "Generate the stub(s), then Edit-fill the {{...}} placeholders:", + ` node .opencode/scripts/spec-scaffold.cjs ${feature} --tasks "R0-01-slug,R1-01-slug,..." --tasks-only`, + "Then use Edit (not Write) on each tasks/task-*.md stub.", + ].join("\n"); +} + +function logCrash(error: unknown): void { + try { + const logDir = join(PLUGIN_DIR, ".logs"); + if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }); + appendFileSync( + join(logDir, "hook-log.jsonl"), + JSON.stringify({ + ts: new Date().toISOString(), + hook: "task-scaffold-guard", + status: "crash", + error: error instanceof Error ? error.message : String(error), + }) + "\n", + ); + } catch { + // fail-open + } +} + +export const TaskScaffoldGuard: Plugin = async ({ directory }) => ({ + "tool.execute.before": async (input, output) => { + try { + if (input.tool !== "write" && input.tool !== "apply_patch" && input.tool !== "edit") return; + + const runtime = readRuntime(directory); + const spec = runtime.spec as Record | undefined; + if (spec?.scaffold_guard === false) return; + + const scaffold = join(directory, ".opencode", "scripts", "spec-scaffold.cjs"); + if (!existsSync(scaffold)) return; + + const args = (output.args ?? {}) as Record; + + // OpenCode's `edit` CAN create new files (unlike Claude's Edit, which + // requires an existing file — the original guard's design premise). + // Verified on opencode 1.17.15. Gate: edit on a NON-existent task file + // is creation (block); edit on an existing stub is legitimate filling. + if (input.tool === "edit") { + const filePath = typeof args.filePath === "string" ? args.filePath.trim() : ""; + if (filePath && isTaskFile(filePath) && !existsSync(resolveTaskFile(directory, filePath))) { + throw new Error(formatBlockMessage(filePath)); + } + return; + } + + const paths = collectTargetPaths(input.tool, args); + for (const filePath of paths) { + if (!isTaskFile(filePath)) continue; + throw new Error(formatBlockMessage(filePath)); + } + } catch (error) { + // Re-throw intentional scaffold blocks; swallow programming errors. + if (error instanceof Error && error.message.includes("TASK SCAFFOLD REQUIRED")) { + throw error; + } + logCrash(error); + } + }, +}); From 41de4d0a631a1be7eb14a657abda5107d1e98072 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 11:07:58 +0700 Subject: [PATCH 08/14] fix(hooks): stop advertising the scaffold-guard escape hatch to the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenCode smoke test proved the self-disarm vector: shown the override line in the block message, the model wrote runtime.json with scaffold_guard:false and bypassed the guard instead of scaffolding. The Claude hook printed the same line — removed. The escape hatch remains functional and documented for humans. --- packages/spec/src/claude/hooks/task-scaffold-guard.cjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/claude/hooks/task-scaffold-guard.cjs b/packages/spec/src/claude/hooks/task-scaffold-guard.cjs index 5eaf289..65f0888 100644 --- a/packages/spec/src/claude/hooks/task-scaffold-guard.cjs +++ b/packages/spec/src/claude/hooks/task-scaffold-guard.cjs @@ -66,6 +66,9 @@ try { if (!fs.existsSync(scaffold)) process.exit(0); // Valve 2: block with an actionable message carrying the exact command. + // NOTE: deliberately does NOT mention the runtime.json escape hatch — an + // OpenCode smoke test proved the model reads the advertised override and + // simply disables the guard itself. The hatch stays functional for humans. const m = norm.match(/(^|\/)specs\/([^/]+)\/tasks\//); const feature = m ? m[2] : ''; console.log( @@ -73,8 +76,7 @@ try { `Blocked Write: ${filePath}\n\n` + `Generate the stub(s), then Edit-fill the {{...}} placeholders:\n` + ` node .claude/scripts/spec-scaffold.cjs ${feature} --tasks "R0-01-slug,R1-01-slug,..." --tasks-only\n` + - `Then use Edit (not Write) on each tasks/task-*.md stub.\n` + - `Override: set "spec": { "scaffold_guard": false } in .claude/runtime.json` + `Then use Edit (not Write) on each tasks/task-*.md stub.` ); process.exit(2); From 256f44b17aa52f501c0b2c4a6dec3fecb36bc406 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 11:07:58 +0700 Subject: [PATCH 09/14] chore(repo): ignore local .opencode/ and update addressing preference Owner-local project preferences, split out of the feature commit for a clean history. --- .gitignore | 3 ++- CLAUDE.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index e8bda21..e624ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,5 @@ plans/ .cafekit.lock .claude .agents -.codex \ No newline at end of file +.codex +.opencode/ diff --git a/CLAUDE.md b/CLAUDE.md index d1e8da1..ff1ffe5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,4 +128,4 @@ Always respond in **Tiếng Việt**. Technical terms, code identifiers, and fil ## Addressing (Context Overflow Indicator) -Claude Code always addresses the user as "đại ca" throughout the conversation. If it stops doing so, it is a sign the context has been compacted/truncated — tell the user to consider `/clear`. +Claude Code always addresses the user as "bro" throughout the conversation. If it stops doing so, it is a sign the context has been compacted/truncated — tell the user to consider `/clear`. From 197a04b23106677f12dee04d3587393927e32dea Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 11:57:45 +0700 Subject: [PATCH 10/14] docs(changelog): merge duplicate Added sections under Unreleased --- docs/project-changelog.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 2c82203..931c131 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -7,6 +7,7 @@ All notable changes to CafeKit are documented here, following ### Added - **OpenCode port Option A — spec-workflow enforcement**: `task-scaffold-guard.ts` (block hand-written task files via `write`/`apply_patch`) and `spec-state.ts` (tollgate inject via `chat.message`); installer self-test expects both plugins. +- **First hook behavioral test harness** (`src/claude/hooks/__tests__/`): runs each hook as a real subprocess (stdin payload → exit code / output) via `node --test`, wired into `run-skill-self-tests.mjs`. Covers the two hook-hardening fixes with regression tests proven to fail against the unpatched code. ### Removed - `generate-graph` and `impact-analysis` skills (unused) + orphaned `scripts/browser-tool.cjs`; routing/manifest/OpenCode wrappers/self-tests cleaned; upgrades remove them from existing installs (PR #66). @@ -20,9 +21,6 @@ All notable changes to CafeKit are documented here, following - **`session.cjs` env escaping**: `writeEnv` now escapes `\ $ \` "` (was `"` only), so an attacker-influenced value flowing into `CLAUDE_ENV_FILE` (e.g. a git branch named `` evil$HOME-x`pwd` ``) can no longer expand or execute when the env file is sourced. Matches the canonical `escapeShellValue` semantics in `lib/config.cjs`. - **`privacy-block.cjs` symlink bypass**: the sensitive-file gate now resolves symlink targets via `fs.realpathSync` and checks both the requested name and its real target, closing the bypass where a harmless-looking symlink (e.g. `notes.txt` → `.env`) slipped past the basename check. Exemptions (`.env.example`) still win on either name; fail-open when the path cannot be resolved. -### Added -- **First hook behavioral test harness** (`src/claude/hooks/__tests__/`): runs each hook as a real subprocess (stdin payload → exit code / output) via `node --test`, wired into `run-skill-self-tests.mjs`. Covers the two fixes above with regression tests proven to fail against the unpatched code. - ## [0.13.2] - 2026-06-21 ### Added — Enforce scaffold on task creation From 0ea97bc22b79e5c6e873418de5de1e65bb53ffd6 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 13:22:43 +0700 Subject: [PATCH 11/14] fix(skills): drop stale gemini-3-pro-preview option and dedupe model list in .env.example --- packages/spec/src/claude/skills/ai-multimodal/.env.example | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/spec/src/claude/skills/ai-multimodal/.env.example b/packages/spec/src/claude/skills/ai-multimodal/.env.example index cdb27e6..190fa0d 100644 --- a/packages/spec/src/claude/skills/ai-multimodal/.env.example +++ b/packages/spec/src/claude/skills/ai-multimodal/.env.example @@ -67,9 +67,7 @@ GEMINI_API_KEY=your_api_key_here # Used by: --task analyze, transcribe, extract # Default: gemma-4-31b-it # Options: -# gemini-3-pro-preview - Latest, agentic workflows, 1M context -# gemma-4-31b-it - Best price/performance (recommended) -# gemma-4-31b-it - Highest quality +# gemma-4-31b-it - Best price/performance (recommended) # MULTIMODAL_MODEL=gemma-4-31b-it # --- Legacy Compatibility --- From 6df8473aa4e83d71a56f7def7df930c47612aec0 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 16:19:00 +0700 Subject: [PATCH 12/14] feat(hooks): add spec-gate Stop hook enforcing verification receipts on newly-done tasks --- .../claude/hooks/__tests__/spec-gate.test.js | 270 ++++++++++++++++++ packages/spec/src/claude/hooks/spec-gate.cjs | 173 +++++++++++ .../spec/src/claude/migration-manifest.json | 1 + packages/spec/src/claude/runtime.json | 4 + .../spec/src/claude/settings/settings.json | 4 + 5 files changed, 452 insertions(+) create mode 100644 packages/spec/src/claude/hooks/__tests__/spec-gate.test.js create mode 100644 packages/spec/src/claude/hooks/spec-gate.cjs diff --git a/packages/spec/src/claude/hooks/__tests__/spec-gate.test.js b/packages/spec/src/claude/hooks/__tests__/spec-gate.test.js new file mode 100644 index 0000000..482c661 --- /dev/null +++ b/packages/spec/src/claude/hooks/__tests__/spec-gate.test.js @@ -0,0 +1,270 @@ +'use strict'; + +// Behavioral tests for spec-gate.cjs (Stop completion gate). The hook is run as +// a real subprocess: a Stop payload is piped to stdin; we assert exit code and +// whether stdout carries {"decision":"block",...}. Cache lives under the hook's +// own .logs/ dir (shared), so each test seeds/clears it deliberately. + +const { test, beforeEach, after } = require('node:test'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const HOOK = path.join(__dirname, '..', 'spec-gate.cjs'); +const CACHE = path.join(__dirname, '..', '.logs', 'spec-gate-last.json'); +const FEATURE = 'demo'; +const TASK_REL = 'tasks/task-R0-01-x.md'; + +function runHook(payload, cwd) { + const projectRoot = cwd || payload.cwd; + // PROJECT_ROOT wins over cwd in the hook; pin it to the fixture so a host + // monorepo's real specs/ cannot leak into the test (e.g. active rtk-* specs). + const env = { ...process.env, PROJECT_ROOT: projectRoot }; + const res = spawnSync(process.execPath, [HOOK], { + input: JSON.stringify({ + hook_event_name: 'Stop', + session_id: 'test', + transcript_path: '/tmp/t', + stop_hook_active: false, + ...payload, + cwd: projectRoot, + }), + encoding: 'utf8', + env, + }); + return { + code: res.status, + stdout: (res.stdout || '').trim(), + stderr: res.stderr || '', + }; +} + +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-')); +} + +function clearCache() { + try { fs.unlinkSync(CACHE); } catch { /* absent */ } +} + +function seedCache(featureMap) { + fs.mkdirSync(path.dirname(CACHE), { recursive: true }); + fs.writeFileSync(CACHE, JSON.stringify(featureMap)); +} + +function readCache() { + return JSON.parse(fs.readFileSync(CACHE, 'utf8')); +} + +/** + * Build a minimal active-spec fixture under dir. + * @param {object} opts + * @param {string} [opts.taskStatus='done'] + * @param {string|null} [opts.completed_at='2026-07-01T00:00:00Z'] + * @param {string} [opts.mdStatus='done'] + * @param {'valid'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid'] + * @param {object|null} [opts.runtime] — if set, write .claude/runtime.json + */ +function makeFixture(opts = {}) { + const { + taskStatus = 'done', + completed_at = '2026-07-01T00:00:00Z', + mdStatus = 'done', + evidence = 'valid', + runtime = null, + } = opts; + const dir = tmpDir(); + const featureDir = path.join(dir, 'specs', FEATURE); + const tasksDir = path.join(featureDir, 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + + const entry = { status: taskStatus }; + if (completed_at !== null) entry.completed_at = completed_at; + + fs.writeFileSync( + path.join(featureDir, 'spec.json'), + JSON.stringify({ + status: 'in_progress', + current_phase: 'implementation', + task_registry: { [TASK_REL]: entry }, + }), + ); + + let evidenceBlock = ''; + if (evidence === 'valid') { + evidenceBlock = [ + '## Evidence', + '', + '```', + 'npm test', + 'PASS: 10 tests', + '```', + '', + ].join('\n'); + } else if (evidence === 'placeholder') { + evidenceBlock = [ + '## Evidence', + '', + 'Command: `{{TYPECHECK / TEST COMMAND}}`', + 'Expected: {{What proves success}}', + '', + ].join('\n'); + } else if (evidence === 'missing-evidence') { + evidenceBlock = '## Risk Assessment\n\nNone.\n'; + } else { + evidenceBlock = ''; + } + + fs.writeFileSync( + path.join(dir, 'specs', FEATURE, TASK_REL), + [ + `# Task R0-01: example`, + '', + `**Status:** ${mdStatus}`, + '', + evidenceBlock, + ].join('\n'), + ); + + if (runtime) { + fs.mkdirSync(path.join(dir, '.claude'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.claude', 'runtime.json'), + JSON.stringify(runtime), + ); + } + + return dir; +} + +function parseBlock(stdout) { + if (!stdout) return null; + // Hook may print only the JSON line. + try { + return JSON.parse(stdout); + } catch { + const line = stdout.split('\n').find((l) => l.includes('"decision"')); + if (!line) return null; + return JSON.parse(line); + } +} + +beforeEach(() => { + clearCache(); +}); + +after(() => { + clearCache(); +}); + +test('1. newly-done task WITHOUT receipt → block, reason names task path', () => { + const dir = makeFixture({ + evidence: 'missing-evidence', + completed_at: '2026-07-01T00:00:00Z', + }); + try { + seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } }); + const { code, stdout } = runHook({}, dir); + assert.strictEqual(code, 0); + const body = parseBlock(stdout); + assert.ok(body, 'expected block JSON on stdout'); + assert.strictEqual(body.decision, 'block'); + assert.ok( + body.reason.includes(TASK_REL), + `reason must name task path; got: ${body.reason}`, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('2. newly-done task WITH valid receipt → no block, cache updated', () => { + const dir = makeFixture({ evidence: 'valid' }); + try { + seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } }); + const { code, stdout } = runHook({}, dir); + assert.strictEqual(code, 0); + assert.strictEqual(stdout, '', 'valid receipt must not produce block output'); + const cache = readCache(); + assert.strictEqual(cache[FEATURE][TASK_REL], 'done'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('3. no active spec → exit 0 silent', () => { + const dir = tmpDir(); + try { + fs.mkdirSync(path.join(dir, 'specs'), { recursive: true }); + // No in_progress spec. + seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } }); + const { code, stdout } = runHook({}, dir); + assert.strictEqual(code, 0); + assert.strictEqual(stdout, ''); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('4. stop_hook_active: true → exit 0 silent even with violating task', () => { + const dir = makeFixture({ evidence: 'missing-evidence' }); + try { + seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } }); + const { code, stdout } = runHook({ stop_hook_active: true }, dir); + assert.strictEqual(code, 0); + assert.strictEqual(stdout, ''); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('5. first run (no cache) with done-without-receipt → exit 0, cache created', () => { + const dir = makeFixture({ evidence: 'missing-evidence' }); + try { + clearCache(); + assert.ok(!fs.existsSync(CACHE), 'cache must be absent for first-run'); + const { code, stdout } = runHook({}, dir); + assert.strictEqual(code, 0); + assert.strictEqual(stdout, '', 'first run must never block'); + assert.ok(fs.existsSync(CACHE), 'cache file must be created'); + const cache = readCache(); + assert.strictEqual(cache[FEATURE][TASK_REL], 'done'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('6. runtime.json spec.completion_gate: false → exit 0 silent', () => { + const dir = makeFixture({ + evidence: 'missing-evidence', + runtime: { spec: { completion_gate: false } }, + }); + try { + seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } }); + const { code, stdout } = runHook({}, dir); + assert.strictEqual(code, 0); + assert.strictEqual(stdout, ''); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('7. Evidence with {{...}} placeholder → blocked (check c)', () => { + const dir = makeFixture({ evidence: 'placeholder' }); + try { + seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } }); + const { code, stdout } = runHook({}, dir); + assert.strictEqual(code, 0); + const body = parseBlock(stdout); + assert.ok(body, 'expected block JSON on stdout'); + assert.strictEqual(body.decision, 'block'); + assert.ok( + body.reason.includes(TASK_REL) && /\bc\b/.test(body.reason), + `reason must cite task path and check c; got: ${body.reason}`, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/spec/src/claude/hooks/spec-gate.cjs b/packages/spec/src/claude/hooks/spec-gate.cjs new file mode 100644 index 0000000..e0457b6 --- /dev/null +++ b/packages/spec/src/claude/hooks/spec-gate.cjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * Copyright (c) 2026 Haposoft. MIT License. + * + * Stop Hook — spec-gate.cjs + * Implements: https://docs.anthropic.com/en/docs/claude-code/hooks + * + * Blocks turn end when *newly-done* tasks lack a verification receipt. + * Block contract (exit 0 + stdout): {"decision":"block","reason":"..."}. + * + * Safety: stop_hook_active loop guard; runtime.spec.completion_gate=false + * escape hatch (missing key → ON); first-run seeds cache without blocking; + * crash → fail-open + hooks/.logs/hook-log.jsonl. Exit: 0 always. + */ + +try { + const fs = require('fs'); + const path = require('path'); + + const stdin = fs.readFileSync(0, 'utf8').trim(); + if (!stdin) process.exit(0); + + const payload = JSON.parse(stdin); + const cwd = payload.cwd || process.cwd(); + + // Never re-block a continuation caused by our own block (infinite-loop guard). + if (payload.stop_hook_active === true) process.exit(0); + + // Missing/malformed runtime.json keeps the gate ON (fail-closed, valve 3 style). + let runtime = {}; + try { + const rp = path.join(cwd, '.claude', 'runtime.json'); + if (fs.existsSync(rp)) runtime = JSON.parse(fs.readFileSync(rp, 'utf8')); + } catch { /* gate stays on */ } + if (runtime.spec && runtime.spec.completion_gate === false) process.exit(0); + + // Active-spec discovery — same rules as spec-state.cjs. + const baseDir = process.env.PROJECT_ROOT || cwd; + const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs'); + if (!fs.existsSync(specsPath)) process.exit(0); + + let activeSpec = null; + let featureName = null; + for (const entry of fs.readdirSync(specsPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const specFile = path.join(specsPath, entry.name, 'spec.json'); + if (!fs.existsSync(specFile)) continue; + try { + const specData = JSON.parse(fs.readFileSync(specFile, 'utf8')); + if (specData.status === 'in_progress' || specData.status === 'in-progress') { + activeSpec = specData; + featureName = entry.name; + break; + } + } catch { /* skip bad JSON */ } + } + if (!activeSpec) process.exit(0); + + const taskRegistry = activeSpec.task_registry || {}; + const cacheFile = path.join(__dirname, '.logs', 'spec-gate-last.json'); + const cacheExists = fs.existsSync(cacheFile); + let cache = {}; + if (cacheExists) { + try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch { cache = {}; } + } + + const currentStatuses = {}; + for (const [tp, task] of Object.entries(taskRegistry)) { + currentStatuses[tp] = task?.status || 'pending'; + } + + // First run: treat all current done as historical — seed cache, never block. + if (!cacheExists) { + try { + fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); + cache[featureName] = currentStatuses; + fs.writeFileSync(cacheFile, JSON.stringify(cache)); + } catch { /* fail-open */ } + process.exit(0); + } + + const featureCache = cache[featureName] || {}; + // Newly done: status is done now AND cached status differs or is absent. + const newlyDone = Object.keys(taskRegistry).filter((tp) => + (taskRegistry[tp]?.status || 'pending') === 'done' && featureCache[tp] !== 'done' + ); + if (newlyDone.length === 0) process.exit(0); + + // /m so ^ matches line starts (Evidence is never at byte 0 of the file). + const EVID_RE = /^#{2,3}\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/m; + const PROOF_RE = /(PASS|FAIL|exit code|passed|✓)/; + + /** Body of first Evidence heading until next same-or-higher heading. */ + function evidenceBody(text) { + const lines = text.split('\n'); + let start = -1, level = 0; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^(#{2,3})\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/); + if (m) { start = i + 1; level = m[1].length; break; } + } + if (start < 0) return null; + let end = lines.length; + for (let i = start; i < lines.length; i++) { + const hm = lines[i].match(/^(#{1,6})\s+/); + if (hm && hm[1].length <= level) { end = i; break; } + } + return lines.slice(start, end).join('\n'); + } + + // Receipt checks a–d → failed letter list. + // a: file + Status header has "done"; b: Evidence heading; c: no {{...}} + (fence|proof); d: completed_at + function checkReceipt(taskPath) { + const fails = []; + const abs = path.join(specsPath, featureName, taskPath); + if (!fs.existsSync(abs)) return ['a']; + let text = ''; + try { text = fs.readFileSync(abs, 'utf8'); } catch { return ['a']; } + + const statusLine = text.split('\n').find((l) => /^\s*(\*\*)?Status(\*\*)?\s*:/i.test(l)); + if (!statusLine || !/\bdone\b/i.test(statusLine)) fails.push('a'); + + const body = evidenceBody(text); + if (body === null || !EVID_RE.test(text)) { + fails.push('b'); + } else if (/\{\{[^}]+\}\}/.test(body) || !(/```/.test(body) || PROOF_RE.test(body))) { + fails.push('c'); + } + + const at = taskRegistry[taskPath]?.completed_at; + if (typeof at !== 'string' || at.trim() === '') fails.push('d'); + return fails; + } + + const failures = newlyDone + .map((tp) => ({ taskPath: tp, fails: checkReceipt(tp) })) + .filter((f) => f.fails.length > 0); + + // Leave failing newly-done at old cache status so the gate re-fires next Stop. + try { + const nextFeature = { ...featureCache, ...currentStatuses }; + for (const { taskPath } of failures) { + if (featureCache[taskPath] === undefined) delete nextFeature[taskPath]; + else nextFeature[taskPath] = featureCache[taskPath]; + } + cache[featureName] = nextFeature; + fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); + fs.writeFileSync(cacheFile, JSON.stringify(cache)); + } catch { /* fail-open */ } + + if (failures.length === 0) process.exit(0); + + const lines = [ + `⚠️ Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`, + ]; + for (const { taskPath, fails } of failures) { + lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(', ')}`); + lines.push( + ` Fix: add a verification receipt to \`## Evidence\` in \`specs/${featureName}/${taskPath}\`: commands run + outcomes, then re-sync spec.json` + ); + } + process.stdout.write(JSON.stringify({ decision: 'block', reason: lines.slice(0, 8).join('\n') }) + '\n'); + process.exit(0); + +} catch (e) { + try { + const fs = require('fs'), p = require('path'); + const d = p.join(__dirname, '.logs'); + if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); + fs.appendFileSync(p.join(d, 'hook-log.jsonl'), + JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-gate', status: 'crash', error: e.message }) + '\n'); + } catch (_) {} + process.exit(0); +} diff --git a/packages/spec/src/claude/migration-manifest.json b/packages/spec/src/claude/migration-manifest.json index 3d652a6..42a2003 100644 --- a/packages/spec/src/claude/migration-manifest.json +++ b/packages/spec/src/claude/migration-manifest.json @@ -93,6 +93,7 @@ "hooks/task-scaffold-guard.cjs", "hooks/rules.cjs", "hooks/spec-state.cjs", + "hooks/spec-gate.cjs", "hooks/state.cjs", "hooks/lib/color.cjs", "hooks/lib/parser.cjs", diff --git a/packages/spec/src/claude/runtime.json b/packages/spec/src/claude/runtime.json index 82c6362..51d34dd 100644 --- a/packages/spec/src/claude/runtime.json +++ b/packages/spec/src/claude/runtime.json @@ -15,6 +15,10 @@ "docs": "docs", "specs": "specs" }, + "spec": { + "scaffold_guard": true, + "completion_gate": true + }, "locale": { "thinkingLanguage": null, "responseLanguage": null diff --git a/packages/spec/src/claude/settings/settings.json b/packages/spec/src/claude/settings/settings.json index 3e6aad4..974d084 100644 --- a/packages/spec/src/claude/settings/settings.json +++ b/packages/spec/src/claude/settings/settings.json @@ -102,6 +102,10 @@ "Stop": [ { "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/spec-gate.cjs\"" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/state.cjs\"" From 80b1ae0c247c6f4c7169bd6d0c0176f2cfe2f1b2 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 16:19:00 +0700 Subject: [PATCH 13/14] refactor(hooks): slim spec-state reminder now that enforcement lives on Stop --- packages/spec/src/claude/hooks/spec-state.cjs | 27 +++++++------------ packages/spec/src/claude/rules/state-sync.md | 4 +++ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/spec/src/claude/hooks/spec-state.cjs b/packages/spec/src/claude/hooks/spec-state.cjs index 5f39acc..8b06b6f 100755 --- a/packages/spec/src/claude/hooks/spec-state.cjs +++ b/packages/spec/src/claude/hooks/spec-state.cjs @@ -98,26 +98,19 @@ try { fs.writeFileSync(cacheFile, stateKey); } catch { /* fail-open: if the write fails we still print the full block */ } - // Format the output + // Compact state-change block (enforcement lives on Stop via spec-gate.cjs). const lines = []; lines.push(''); - lines.push('### 🔴 URGENT SYSTEM TOLLGATE (STATE SYNC) 🔴'); - lines.push(`- **Active Feature:** \`${featureName}\``); - lines.push(`- **Current Phase:** \`${phase}\``); - if (taskEntries.length > 0) { - lines.push(`- **Task Registry:** \`${taskEntries.length} total | ${(taskCounts.done || 0)} done | ${(taskCounts.in_progress || 0)} in_progress | ${(taskCounts.blocked || 0)} blocked | ${(taskCounts.pending || 0)} pending\``); - if (nextUnblocked) { - lines.push(`- **Next Unblocked Task:** \`${nextUnblocked[0]}\``); - } + lines.push(`### Spec state changed: \`${featureName}\``); + lines.push(`- Phase: \`${phase}\` | Tasks: ${(taskCounts.done || 0)} done / ${taskEntries.length} total` + + (taskEntries.length > 0 + ? ` (${(taskCounts.in_progress || 0)} in_progress, ${(taskCounts.pending || 0)} pending, ${(taskCounts.blocked || 0)} blocked)` + : '')); + if (nextUnblocked) { + lines.push(`- Next unblocked: \`${nextUnblocked[0]}\``); } - lines.push(''); - lines.push(`> BẮT BUỘC (MANDATORY): Nếu bạn vừa hoàn thành một bước, bạn KHÔNG ĐƯỢC báo cáo "Đã xong" ngay.`); - lines.push(`> Bạn PHẢI sử dụng công cụ Edit để cập nhật trạng thái vật lý sau khi đã có bằng chứng verify thật (build/test/runtime/artifact), không phải chỉ vì code đã viết xong.`); - lines.push(`> 1. Sửa file \`spec.json\` (status, phase/current_phase, timestamps, \`task_files\`, \`task_registry\`, validation state nếu có thay đổi).`); - lines.push(`> 2. Chỉ khi verify xong mới sửa file \`tasks/task-*.md\` (status + tick '[x]' các sub-task và completion criteria liên quan).`); - lines.push(`> 3. Trước khi set \`ready_for_implementation = true\`, PHẢI chạy \`node .claude/scripts/validate-spec-output.cjs specs/${featureName}\` và sửa mọi lỗi.`); - lines.push(`> 4. NẾU VỪA HOÀN THÀNH 1 TASK CÓ SỬA SOURCE CODE, BẮT BUỘC cập nhật ngay tài liệu trong \`docs/\` (\`system-architecture.md\` hoặc Changelog) cho đồng bộ.`); - lines.push(`> CẤM VI PHẠM LUẬT TOLLGATE NÀY NHẰM ĐẢM BẢO TÍNH ĐỒNG BỘ CỦA HỆ THỐNG.`); + lines.push(`- Sync \`spec.json\` + task file after verified work; run \`node .claude/scripts/validate-spec-output.cjs specs/${featureName}\` before \`ready_for_implementation=true\`.`); + lines.push(`- A completion gate verifies receipts when you end a turn with newly-done tasks.`); lines.push(''); console.log(lines.join('\n')); diff --git a/packages/spec/src/claude/rules/state-sync.md b/packages/spec/src/claude/rules/state-sync.md index 5529b14..75e9791 100644 --- a/packages/spec/src/claude/rules/state-sync.md +++ b/packages/spec/src/claude/rules/state-sync.md @@ -25,3 +25,7 @@ Before returning control to the user or orchestrator, the agent **MUST**: **Canonical state values:** New specs MUST use `status: "in_progress"` for active work. Legacy `in-progress` may be read for compatibility, but must not be emitted in new files. **Golden Rule:** If the current phase changes, or a task completes, the agent must update the physical files. Never mark a task completed before there is execution proof, and never let `task_registry` disagree with the matching markdown task file. The context is intentionally NOT persisted in the chat to save tokens. An injected Hook (`spec-state.cjs`) constantly enforces and validates this state. + +### Completion gate (Stop) + +Marking a task `done` is now machine-gated on the **Stop** hook (`spec-gate.cjs`), not only reminded on each prompt. When a turn ends with *newly-done* tasks, the gate requires a verification receipt in each task markdown: `Status: done`, an Evidence section (or legacy `Task Test Plan & Verification Evidence` / `Verification & Evidence`) with real proof (no `{{...}}` placeholders; at least one fenced command block or PASS/FAIL line), and a non-empty `task_registry[path].completed_at`. First adoption (no cache yet) seeds history without blocking. Escape hatch for humans: `"spec": { "completion_gate": false }` in `.claude/runtime.json` (missing key keeps the gate ON). From 74ca18cb9e86ca180f3c3ee6d63c3760a44ca044 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Fri, 17 Jul 2026 16:19:01 +0700 Subject: [PATCH 14/14] docs(changelog): record tollgate v2 (spec-gate + slimmed reminder) --- docs/project-changelog.md | 4 ++++ packages/spec/CHANGELOG.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 931c131..b0bbabd 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,9 +6,13 @@ All notable changes to CafeKit are documented here, following ## [Unreleased] ### Added +- **`spec-gate.cjs` (Stop completion gate)**: machine-enforced receipt check when the assistant ends a turn with newly-done tasks (Evidence section + proof, no placeholders, `completed_at` set). First-run seeds cache without blocking; escape hatch `spec.completion_gate: false`. Complements the soft `spec-state` reminder (audit §3.4.1). - **OpenCode port Option A — spec-workflow enforcement**: `task-scaffold-guard.ts` (block hand-written task files via `write`/`apply_patch`) and `spec-state.ts` (tollgate inject via `chat.message`); installer self-test expects both plugins. - **First hook behavioral test harness** (`src/claude/hooks/__tests__/`): runs each hook as a real subprocess (stdin payload → exit code / output) via `node --test`, wired into `run-skill-self-tests.mjs`. Covers the two hook-hardening fixes with regression tests proven to fail against the unpatched code. +### Changed +- **`spec-state.cjs` reminder slimmed**: state-change block is compact English only (no red ALL-CAPS / bilingual wall); one-line unchanged path kept. Hard completion enforcement is now the Stop gate. + ### Removed - `generate-graph` and `impact-analysis` skills (unused) + orphaned `scripts/browser-tool.cjs`; routing/manifest/OpenCode wrappers/self-tests cleaned; upgrades remove them from existing installs (PR #66). diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 9ee088d..1356e24 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`spec-gate.cjs` (Stop completion gate)**: blocks turn end when *newly-done* tasks lack a verification receipt in their task markdown (`Status: done`, Evidence section with real proof — no `{{...}}` placeholders — plus non-empty `task_registry[].completed_at`). Loop-safe via `stop_hook_active`; first run (no cache) seeds history without blocking so legacy specs adopt cleanly. Escape hatch: `"spec": { "completion_gate": false }` in `.claude/runtime.json` (missing key keeps the gate ON). Registered first on the existing `Stop` hooks list (before `state.cjs`). Follow-up idea: a dedicated `TaskCompleted` event is not used here — its output contract is unverified on the target Claude Code version. - **OpenCode `task-scaffold-guard.ts` plugin**: ports Claude `task-scaffold-guard.cjs` — hard-blocks `write` and `apply_patch` creating `specs//tasks/task-*.md`, forcing generation via `spec-scaffold.cjs` then Edit-fill. Also gates `edit` on a **non-existent** task file: OpenCode's `edit` can create files (unlike Claude's Edit — smoke-verified on opencode 1.17.15), so edit-creation is blocked while stub-filling stays allowed. Safety valves: runtime escape hatch (functional but **not advertised in the block message** — a smoke test proved the model reads the advertised override and disables the guard itself), fail-open if the scaffold script is missing, actionable block message. Auto-discovered by the OpenCode installer. - **OpenCode `spec-state.ts` plugin**: ports Claude `spec-state.cjs` tollgate via `chat.message` — injects state-sync reminder (fingerprint gate: one-line when unchanged, full URGENT block when phase/done-total changes). Injected parts are schema-complete (`id`/`sessionID`/`messageID`) — a bare `{type,text}` part crashes the whole user turn ("invalid user part before save"). Disable with `"spec": { "tollgate": false }` in `.opencode/runtime.json`. End-to-end verified on opencode 1.17.15 (tollgate text reaches the model). @@ -29,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **frontend-design skill**: frontmatter pointed at a non-existent `LICENSE.txt`; corrected to `license: MIT` (no Anthropic-derived content found in the skill folder). ### Changed +- **`spec-state.cjs` tollgate reminder slimmed**: state-change path is now a compact English block (≤7 lines: feature, phase, task counts, next unblocked task, sync/validate rule, Stop-gate note). Removed the red ALL-CAPS / bilingual MANDATORY wall (`URGENT`, `BẮT BUỘC`, `CẤM`). One-line unchanged-fingerprint path is unchanged. Completion enforcement moved to `spec-gate.cjs` on Stop. - Installer obsolete mechanism removes directories recursively and prunes ownership-manifest entries by prefix (`tracker.prunePrefix`), enabling skill-level cleanup on upgrade. ## [0.13.2] - 2026-06-21