@haposoft/cafekit 0.15.3 → 0.16.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/bin/__tests__/codex-hooks.test.js +140 -0
- package/bin/__tests__/codex-native.test.js +2 -2
- package/bin/__tests__/develop-contract.test.js +1120 -0
- package/bin/__tests__/installer-safety.test.js +510 -2
- package/bin/__tests__/package-inventory.test.js +258 -0
- package/bin/__tests__/secret-scanner.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +713 -0
- package/bin/install.js +5 -1
- package/bin/lib/codex-install.js +21 -6
- package/bin/lib/context.js +17 -6
- package/bin/lib/copy-utils.js +31 -0
- package/bin/lib/instruction-blocks.js +85 -0
- package/bin/lib/managed-writer.js +4 -1
- package/bin/lib/opencode-install.js +75 -30
- package/bin/phases/claude-runtime.js +103 -4
- package/bin/phases/codex-runtime.js +9 -5
- package/bin/phases/post-install.js +29 -22
- package/bin/phases/root-config.js +89 -10
- package/package.json +14 -4
- package/src/claude/CLAUDE.md +6 -122
- package/src/claude/agents/code-auditor.md +21 -22
- package/src/claude/agents/debugger.md +3 -3
- package/src/claude/agents/deployer.md +1 -1
- package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
- package/src/claude/agents/project-manager.md +1 -1
- package/src/claude/agents/spec-maker.md +2 -2
- package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
- package/src/claude/hooks/agent.cjs +7 -14
- package/src/claude/hooks/rules.cjs +33 -53
- package/src/claude/hooks/spec-gate.cjs +66 -21
- package/src/claude/hooks/spec-state.cjs +5 -0
- package/src/claude/migration-manifest.json +9 -2
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
- package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
- package/src/claude/scripts/spec-ground.cjs +189 -83
- package/src/claude/scripts/validate-spec-output.cjs +294 -30
- package/src/claude/scripts/workflow-policy.cjs +609 -0
- package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
- package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
- package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
- package/src/claude/skills/backend-development/SKILL.md +4 -4
- package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
- package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
- package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
- package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
- package/src/claude/skills/backend-development/references/backend-security.md +3 -3
- package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
- package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
- package/src/claude/skills/brainstorm/SKILL.md +2 -3
- package/src/claude/skills/code-review/SKILL.md +12 -8
- package/src/claude/skills/code-review/references/verification-gate.md +4 -4
- package/src/claude/skills/debug/SKILL.md +2 -0
- package/src/claude/skills/develop/SKILL.md +100 -52
- package/src/claude/skills/develop/references/parallel-waves.md +105 -26
- package/src/claude/skills/develop/references/quality-gate.md +58 -38
- package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
- package/src/claude/skills/git/SKILL.md +44 -50
- package/src/claude/skills/hotfix/SKILL.md +10 -8
- package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
- package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
- package/src/claude/skills/inspect/SKILL.md +12 -30
- package/src/claude/skills/mobile-development/SKILL.md +12 -12
- package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
- package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
- package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
- package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
- package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
- package/src/claude/skills/question/SKILL.md +13 -13
- package/src/claude/skills/research/SKILL.md +2 -2
- package/src/claude/skills/specs/SKILL.md +32 -15
- package/src/claude/skills/specs/references/review.md +3 -3
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/spec-state.json +7 -3
- package/src/claude/skills/sync/SKILL.md +26 -1
- package/src/claude/skills/sync/references/sync-protocols.md +3 -3
- package/src/claude/skills/test/SKILL.md +12 -4
- package/src/claude/skills/test/references/failure-triage.md +17 -17
- package/src/claude/skills/test/references/test-memory.md +2 -2
- package/src/codex/AGENTS.md +16 -24
- package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
- package/src/codex/hooks/rules.cjs +27 -26
- package/src/codex/hooks/spec-gate.cjs +19 -8
- package/src/codex/hooks/spec-state.cjs +10 -1
- package/src/common/AGENTS.md +43 -0
- package/src/opencode/AGENTS.md +21 -121
- package/src/opencode/plugins/rules.ts +26 -34
- package/src/opencode/plugins/spec-gate.ts +283 -0
- package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
|
@@ -11,6 +11,10 @@ const {
|
|
|
11
11
|
readPayload
|
|
12
12
|
} = require('./lib/hook-context.cjs');
|
|
13
13
|
const { findActiveSpec } = require('./lib/spec-utils.cjs');
|
|
14
|
+
const policyPath = path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs');
|
|
15
|
+
const POLICY = require(fs.existsSync(policyPath)
|
|
16
|
+
? policyPath
|
|
17
|
+
: path.join(__dirname, '../../claude/scripts/workflow-policy.cjs'));
|
|
14
18
|
|
|
15
19
|
function cacheFile(projectRoot, sessionId) {
|
|
16
20
|
const key = crypto.createHash('sha256')
|
|
@@ -29,7 +33,9 @@ try {
|
|
|
29
33
|
if (!active) process.exit(0);
|
|
30
34
|
|
|
31
35
|
const phase = active.spec.current_phase || active.spec.phase || 'unknown';
|
|
32
|
-
const
|
|
36
|
+
const taskRegistry = active.spec.task_registry || {};
|
|
37
|
+
const flashTasks = POLICY.flashState(taskRegistry);
|
|
38
|
+
const tasks = Object.entries(taskRegistry);
|
|
33
39
|
const counts = tasks.reduce((result, [, task]) => {
|
|
34
40
|
const status = task?.status || 'pending';
|
|
35
41
|
result[status] = (result[status] || 0) + 1;
|
|
@@ -62,6 +68,9 @@ try {
|
|
|
62
68
|
`- Phase: \`${phase}\` | Tasks: ${counts.done || 0} done / ${tasks.length} total`
|
|
63
69
|
];
|
|
64
70
|
if (next) lines.push(`- Next unblocked: \`${next[0]}\``);
|
|
71
|
+
if (flashTasks.length > 0) {
|
|
72
|
+
lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. PASS promotion keeps task in_progress until explicit sync-finalize.`);
|
|
73
|
+
}
|
|
65
74
|
lines.push(
|
|
66
75
|
'- Sync `spec.json` and the task file only after verified work.',
|
|
67
76
|
`- Validate with \`node .codex/scripts/validate-spec-output.cjs specs/${active.featureName}\`.`,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
## Shared CafeKit instructions
|
|
4
|
+
|
|
5
|
+
- Deliver exactly what was asked. Do not expand, polish, or add optional work beyond the request. Match existing code style and structure.
|
|
6
|
+
- For spec work, `Completion Criteria` and `## Evidence` in `specs/<feature>/tasks/*.md` are the source of truth for task state.
|
|
7
|
+
- `NO_TESTS` and `0 tests + exit 0` do not pass when the task requires automated tests.
|
|
8
|
+
- When a hook blocks an action, that is an instruction boundary — do not work around it.
|
|
9
|
+
- Use conventional commits. Do not add AI attribution unless requested.
|
|
10
|
+
|
|
11
|
+
## Response style
|
|
12
|
+
|
|
13
|
+
Keep replies focused and brief. Match written-file length to task needs. Do not add filler sections or redundant summaries. Lead with the outcome.
|
|
14
|
+
|
|
15
|
+
## Uncertainty
|
|
16
|
+
|
|
17
|
+
Say when you are unsure and what would settle it. Label inferences instead of presenting them as verified facts.
|
|
18
|
+
|
|
19
|
+
## Delegation
|
|
20
|
+
|
|
21
|
+
Do the work yourself when it takes a handful of tool calls. Delegate genuinely independent parallel tracks. Verification comes from the project's hooks and validators, not from spawning more agents.
|
|
22
|
+
|
|
23
|
+
## Runtime ownership
|
|
24
|
+
|
|
25
|
+
- This `CORE` block is runtime-neutral and safe for every runtime.
|
|
26
|
+
- Runtime-specific instructions live in that runtime's own managed block, not in `CORE`.
|
|
27
|
+
- In a combined install, consume `CORE` plus your native block only. Ignore managed blocks not owned by your runtime. If ownership is unclear, treat the file as `CORE`-only (fail-safe).
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
<!-- Add project-specific install, test, lint, and build commands here. Keep commands executable. -->
|
|
32
|
+
|
|
33
|
+
## Do not touch
|
|
34
|
+
|
|
35
|
+
<!-- List files, directories, generated artifacts, or secrets that tasks must leave unchanged. -->
|
|
36
|
+
|
|
37
|
+
## Slow or expensive
|
|
38
|
+
|
|
39
|
+
<!-- Note commands, environments, or operations that need explicit planning before running. -->
|
|
40
|
+
|
|
41
|
+
## Language Consistency <!-- cafekit:lang -->
|
|
42
|
+
|
|
43
|
+
Match the language the user writes in. Technical terms, code identifiers, and file paths may remain in English.
|
package/src/opencode/AGENTS.md
CHANGED
|
@@ -1,139 +1,39 @@
|
|
|
1
|
-
# AGENTS.md
|
|
2
|
-
|
|
3
1
|
Primary operating instructions for OpenCode using the CafeKit runtime.
|
|
4
2
|
|
|
5
3
|
## OpenCode Runtime Mapping
|
|
6
4
|
|
|
7
5
|
- Project instructions live in `AGENTS.md`.
|
|
8
|
-
- CafeKit commands live in `.opencode/commands/` and use OpenCode-native slash names
|
|
9
|
-
- CafeKit
|
|
10
|
-
- CafeKit skills live in `.opencode/skills/` and are read natively by OpenCode.
|
|
11
|
-
-
|
|
12
|
-
-
|
|
6
|
+
- CafeKit commands live in `.opencode/commands/` and use OpenCode-native slash names.
|
|
7
|
+
- CafeKit agents live in `.opencode/agents/` using OpenCode frontmatter.
|
|
8
|
+
- CafeKit skills live in `.opencode/skills/` and are read natively by OpenCode; edit project-local skills there.
|
|
9
|
+
- Run Python skill scripts with the project venv:
|
|
10
|
+
- macOS/Linux: `.opencode/skills/.venv/bin/python3 scripts/<script>.py`
|
|
11
|
+
- Windows: `.opencode\\skills\\.venv\\Scripts\\python.exe scripts\\<script>.py`
|
|
12
|
+
- Support files live in `.opencode/rules/`, `.opencode/scripts/`, `.opencode/references/`, and `.opencode/runtime.json`.
|
|
13
|
+
- OpenCode config is merged into `opencode.json`; keep project model/provider choices there.
|
|
13
14
|
|
|
14
|
-
## Command
|
|
15
|
+
## Command surface
|
|
15
16
|
|
|
16
|
-
Use these OpenCode commands:
|
|
17
|
+
Use these OpenCode commands without the `hapo:` prefix:
|
|
17
18
|
|
|
18
|
-
- `/
|
|
19
|
-
- `/
|
|
19
|
+
- `/question <question>`: answer from repository evidence first.
|
|
20
|
+
- `/brainstorm <idea-or-problem>`: narrow unclear ideas before specs.
|
|
20
21
|
- `/specs <feature-or-spec-command>`: create, update, validate, or approve specs.
|
|
21
22
|
- `/develop <feature> [task-file] [--flash]`: implement approved spec tasks.
|
|
22
23
|
- `/test [scope|--full]`: verify implementation and collect evidence.
|
|
23
|
-
- `/code-review [scope|--pending]`: review
|
|
24
|
-
- `/debug <issue>`: diagnose
|
|
25
|
-
- `/hotfix <issue>`: apply a narrow, scout-first production fix.
|
|
26
|
-
- `/docs [--init|--update|--summarize|--reconstruct]`: maintain or reconstruct project docs.
|
|
27
|
-
- `/inspect <target>`: inspect source, artifacts, or external context.
|
|
28
|
-
|
|
29
|
-
The underlying CafeKit skills are still named `hapo:*` in their documentation. In OpenCode, use the commands above; each command reads the matching skill from `.opencode/skills/<skill>/SKILL.md`.
|
|
30
|
-
|
|
31
|
-
## Core Objective
|
|
32
|
-
|
|
33
|
-
Act as the project orchestrator: understand the request, keep scope tight, use the right CafeKit skill/agent, and deliver verified work that follows the project's architecture.
|
|
34
|
-
|
|
35
|
-
## Core Behavior
|
|
36
|
-
|
|
37
|
-
These rules reduce common agent coding failures: hidden assumptions, overbuilt solutions, unrelated edits, and unverified completion claims. They take priority over speed-oriented shortcuts.
|
|
38
|
-
|
|
39
|
-
### 1. Think Before Coding
|
|
40
|
-
|
|
41
|
-
- Do not assume silently. State assumptions when they affect the work.
|
|
42
|
-
- If multiple interpretations are plausible, surface them before implementation.
|
|
43
|
-
- If the simpler option is likely better, say so and push back.
|
|
44
|
-
- If the user asks a question about the project, use `/question` to answer from source evidence before planning.
|
|
45
|
-
- Before feature planning or coding, read `./README.md` for project context.
|
|
46
|
-
|
|
47
|
-
### 2. Simplicity First
|
|
48
|
-
|
|
49
|
-
- Solve the requested problem with the smallest maintainable change.
|
|
50
|
-
- Do not add speculative features, future-proofing, or single-use abstractions.
|
|
51
|
-
- Reuse existing modules before creating new ones.
|
|
52
|
-
- If code grows past 200 lines and could be materially simpler, consider splitting it by real boundaries.
|
|
53
|
-
- Prefer YAGNI, KISS, and DRY in that order.
|
|
54
|
-
|
|
55
|
-
### 3. Surgical Changes
|
|
56
|
-
|
|
57
|
-
- Touch only files required by the task.
|
|
58
|
-
- Do not refactor adjacent code, comments, or formatting unless needed for the requested change.
|
|
59
|
-
- Match existing style even if you would choose another style in a new project.
|
|
60
|
-
- Remove only dead code/imports created by your own change.
|
|
61
|
-
- Mention unrelated issues instead of fixing them opportunistically.
|
|
62
|
-
|
|
63
|
-
### 4. Goal-Driven Execution
|
|
64
|
-
|
|
65
|
-
- Convert requests into verifiable success criteria.
|
|
66
|
-
- For spec tasks, use `Completion Criteria` and `Evidence` as the source of truth. Existing task files may use `Task Test Plan & Verification Evidence` or legacy `Verification & Evidence`.
|
|
67
|
-
- For bugs, reproduce with a failing test or concrete evidence when feasible before fixing.
|
|
68
|
-
- Loop until verification passes or a real blocker is recorded.
|
|
69
|
-
|
|
70
|
-
## CafeKit Operating Loop
|
|
71
|
-
|
|
72
|
-
Use this loop for non-trivial work:
|
|
73
|
-
|
|
74
|
-
1. Understand: read README, relevant docs, active spec/task, and existing code.
|
|
75
|
-
2. Plan: choose the smallest coherent path; use `/question` for evidence-backed project questions and `/specs` for feature specs when ready.
|
|
76
|
-
3. Execute: implement only the active task/scope; no placeholder completion.
|
|
77
|
-
4. Verify: run exact task commands first, then repo-level lint/test/build as needed.
|
|
78
|
-
5. Sync: mark task state only after proof exists.
|
|
79
|
-
|
|
80
|
-
## OpenCode Runtime Limits And Replacements
|
|
81
|
-
|
|
82
|
-
- Claude Code hooks/statusline/settings do not run in OpenCode.
|
|
83
|
-
- Treat `.claude/rules/*` as explicit reference material, not injected hook context.
|
|
84
|
-
- Map Claude-specific tools to OpenCode built-ins: `TodoWrite` → the `todowrite` tool; `AskUserQuestion` → the `question` tool; `Task` → OpenCode's agent/subtask flow.
|
|
85
|
-
- Privacy and inspect gates are instruction-level gates in OpenCode unless a project-specific OpenCode plugin adds hard enforcement.
|
|
86
|
-
|
|
87
|
-
## Operating Discipline
|
|
88
|
-
|
|
89
|
-
- If a CafeKit skill may apply, read and use that skill before acting.
|
|
90
|
-
- No completion claim without fresh evidence from the current run: command output, artifact inspection, runtime proof, or an explicitly recorded blocker.
|
|
91
|
-
- For bugs, CI failures, and regressions, diagnose root cause before editing. Symptom patches are not completion.
|
|
92
|
-
- For implementation work, keep each task scoped to one clear owner/context. Reviewers should receive task files, diffs, and acceptance criteria, not chat history.
|
|
93
|
-
- For branch closeout, verify first, then choose an explicit finish action: merge, push/PR, keep branch/worktree, or discard with confirmation.
|
|
94
|
-
|
|
95
|
-
## Definition Of Done
|
|
96
|
-
|
|
97
|
-
A task is done only when all apply:
|
|
98
|
-
|
|
99
|
-
- implementation satisfies `Completion Criteria`
|
|
100
|
-
- `Evidence` is satisfied with concrete proof
|
|
101
|
-
- preflight/build/test outcomes are passing or an explicit blocker is recorded
|
|
102
|
-
- code review has no critical issues
|
|
103
|
-
- a verification receipt exists before task state is synced to `done`
|
|
104
|
-
|
|
105
|
-
`NO_TESTS` and `0 tests + exit 0` are not passing outcomes when the task requires automated tests.
|
|
106
|
-
|
|
107
|
-
## Rule References
|
|
108
|
-
|
|
109
|
-
Consult these when the task touches the relevant area:
|
|
24
|
+
- `/code-review [scope|--pending]`: review correctness and regressions.
|
|
25
|
+
- `/debug <issue>` and `/hotfix <issue>`: diagnose, then apply narrow fixes.
|
|
110
26
|
|
|
111
|
-
|
|
112
|
-
- Development rules: `./.claude/rules/ai-dev-rules.md`
|
|
113
|
-
- Subagent coordination: `./.claude/rules/orchestrator.md`
|
|
114
|
-
- Docs maintenance: `./.claude/rules/manage-docs.md`
|
|
115
|
-
- State sync: `./.claude/rules/state-sync.md`
|
|
116
|
-
- Hook handling reference: `./.claude/rules/hook-protocols.md`
|
|
117
|
-
- Other protocols: `./.claude/rules/*`
|
|
27
|
+
The underlying CafeKit skills are still named `hapo:*` in their documentation.
|
|
118
28
|
|
|
119
|
-
##
|
|
29
|
+
## OpenCode limits
|
|
120
30
|
|
|
121
|
-
-
|
|
122
|
-
- If there is a reasonable chance a skill applies, prefer the skill workflow over ad hoc execution.
|
|
123
|
-
- If modifying skills, edit the current project/runtime files, not `~/.claude/skills` directly.
|
|
124
|
-
- Run Python skill scripts with the skill venv:
|
|
125
|
-
- macOS/Linux: `.claude/skills/.venv/bin/python3 scripts/<script>.py`
|
|
126
|
-
- Windows: `.claude\skills\.venv\Scripts\python.exe scripts\<script>.py`
|
|
127
|
-
- If a skill script fails, diagnose and fix the script or environment instead of abandoning the task.
|
|
31
|
+
Claude Code hooks, statusline, and settings do not run in OpenCode. Map Claude-only tools to OpenCode built-ins: `TodoWrite` → `todowrite`, `AskUserQuestion` → `question`, `Task` → the agent/subtask flow. The installed plugins under `.opencode/plugins/` provide the privacy, inspect-scope, spec-state, scaffold-guard, session-state, and docs-sync gates; other Claude runtime behavior has no OpenCode equivalent. The completion gate (`spec-gate`: `Verification: PASS` + `## Evidence` + `task_registry.completed_at`) is **advisory in OpenCode** — OpenCode has no Stop-hook equivalent and the `spec-gate` plugin (if present) runs as a best-effort `tool.execute.after` check, not a hard turn block like Claude/Codex.
|
|
128
32
|
|
|
129
|
-
|
|
33
|
+
Runtime tier: OpenCode is **tier-2** for completion enforcement. Do not claim parity with Claude/Codex on `completion_gate`; `done` without a receipt emits an advisory warning/banner but does not hard-block the turn — rely on `spec-state` tollgate + manual `/hapo:test` verification.
|
|
130
34
|
|
|
131
|
-
-
|
|
132
|
-
- Do not add AI attribution by default.
|
|
133
|
-
- Lint before commit and run the full required verification before push.
|
|
134
|
-
- Keep commits focused on actual changes.
|
|
135
|
-
- Reports should be concise; list unresolved questions or blockers at the end.
|
|
35
|
+
## Combined-install boundary
|
|
136
36
|
|
|
137
|
-
|
|
37
|
+
OpenCode's native project instruction surface is root `AGENTS.md`. Combined installs keep this OpenCode block there because CafeKit has not proven a separate project instruction entrypoint for OpenCode. Other runtimes may read the same root file; this shared-root trade-off is intentional and must not be treated as filesystem isolation.
|
|
138
38
|
|
|
139
|
-
|
|
39
|
+
**Ownership / ignore contract (fail-safe):** This OpenCode block is owned by OpenCode only. If you are Claude Code, Codex CLI, or any other runtime, ignore this entire OpenCode block and consume only CORE plus your native block. If you cannot determine which block is yours, treat the file as CORE-only. Do not treat another runtime's block as instructions.
|
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
22
|
+
import {
|
|
23
|
+
createHash,
|
|
24
|
+
} from "node:crypto";
|
|
22
25
|
import {
|
|
23
26
|
existsSync,
|
|
27
|
+
mkdirSync,
|
|
24
28
|
readFileSync,
|
|
25
29
|
writeFileSync,
|
|
26
30
|
} from "node:fs";
|
|
@@ -36,7 +40,7 @@ function resolveProjectDir(directory?: string): string {
|
|
|
36
40
|
return dirname(dirname(PLUGIN_DIR));
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
function readRuntime(cwd: string): Record<string, any> {
|
|
43
|
+
function readRuntime(cwd: string): Record<string, any> | null {
|
|
40
44
|
try {
|
|
41
45
|
const file = join(cwd, ".opencode", "runtime.json");
|
|
42
46
|
if (existsSync(file)) {
|
|
@@ -44,12 +48,27 @@ function readRuntime(cwd: string): Record<string, any> {
|
|
|
44
48
|
}
|
|
45
49
|
} catch {
|
|
46
50
|
/* fail-open */
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function reserveSession(cwd: string, sessionId?: string): boolean {
|
|
57
|
+
if (!sessionId) return false;
|
|
58
|
+
try {
|
|
59
|
+
const key = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
|
|
60
|
+
const file = join(cwd, ".opencode", "plugins", ".logs", `rules-${key}.json`);
|
|
61
|
+
mkdirSync(join(cwd, ".opencode", "plugins", ".logs"), { recursive: true });
|
|
62
|
+
writeFileSync(file, JSON.stringify({ sessionId }), { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
47
66
|
}
|
|
48
|
-
return {};
|
|
49
67
|
}
|
|
50
68
|
|
|
51
|
-
function renderDynamicRules(cwd: string): string {
|
|
69
|
+
function renderDynamicRules(cwd: string): string | null {
|
|
52
70
|
const runtime = readRuntime(cwd);
|
|
71
|
+
if (runtime === null) return null;
|
|
53
72
|
const thinkLang: string = runtime?.locale?.thinkingLanguage || "";
|
|
54
73
|
const respondLang: string = runtime?.locale?.responseLanguage || "";
|
|
55
74
|
const effectThink = thinkLang || (respondLang ? "en" : "");
|
|
@@ -77,34 +96,6 @@ function renderDynamicRules(cwd: string): string {
|
|
|
77
96
|
"- **DO NOT** create markdown files outside of those directories unless explicitly asked.",
|
|
78
97
|
);
|
|
79
98
|
lines.push(`- docs.maxLoc: ${maxLoc} lines max per doc file`);
|
|
80
|
-
lines.push("- Follow **YAGNI · KISS · DRY** principles");
|
|
81
|
-
lines.push(
|
|
82
|
-
"- Sacrifice grammar for concision in reports. List unresolved Qs at end.",
|
|
83
|
-
);
|
|
84
|
-
lines.push("- Ensure token efficiency while maintaining high quality.");
|
|
85
|
-
lines.push("");
|
|
86
|
-
|
|
87
|
-
lines.push("### Skill Routing");
|
|
88
|
-
lines.push(
|
|
89
|
-
"- Choose skills from intent using `.opencode/rules/skill-workflow-routing.md` and `.opencode/rules/skill-domain-routing.md`.",
|
|
90
|
-
);
|
|
91
|
-
lines.push(
|
|
92
|
-
"- Use the OpenCode slash commands installed under `.opencode/commands/` (no `hapo:` prefix).",
|
|
93
|
-
);
|
|
94
|
-
lines.push(
|
|
95
|
-
"- Explicit user commands and direct-answer requests override routing suggestions.",
|
|
96
|
-
);
|
|
97
|
-
lines.push("");
|
|
98
|
-
|
|
99
|
-
lines.push("### Modularization");
|
|
100
|
-
lines.push("- If a file exceeds 200 lines, consider splitting it");
|
|
101
|
-
lines.push("- Check existing modules before creating new ones");
|
|
102
|
-
lines.push(
|
|
103
|
-
"- Prefer kebab-case (JS/TS/Python/shell); PascalCase (C#/Java); snake_case (Go/Rust)",
|
|
104
|
-
);
|
|
105
|
-
lines.push(
|
|
106
|
-
"- Skip modularization for: markdown, plain text, bash scripts, config files, .env files",
|
|
107
|
-
);
|
|
108
99
|
lines.push("");
|
|
109
100
|
lines.push(RULES_BLOCK_END);
|
|
110
101
|
|
|
@@ -145,16 +136,17 @@ function upsertBlock(filePath: string, block: string): boolean {
|
|
|
145
136
|
export const RulesPlugin: Plugin = async ({ directory }) => {
|
|
146
137
|
const cwd = resolveProjectDir(directory);
|
|
147
138
|
|
|
148
|
-
const refresh = () => {
|
|
139
|
+
const refresh = (sessionId?: string) => {
|
|
140
|
+
if (!reserveSession(cwd, sessionId)) return;
|
|
149
141
|
const agentsFile = join(cwd, "AGENTS.md");
|
|
150
142
|
const block = renderDynamicRules(cwd);
|
|
151
|
-
upsertBlock(agentsFile, block);
|
|
143
|
+
if (block) upsertBlock(agentsFile, block);
|
|
152
144
|
};
|
|
153
145
|
|
|
154
146
|
return {
|
|
155
147
|
event: async ({ event }) => {
|
|
156
148
|
if (event.type === "session.created") {
|
|
157
|
-
refresh();
|
|
149
|
+
refresh(event.properties?.info?.id);
|
|
158
150
|
}
|
|
159
151
|
},
|
|
160
152
|
};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Haposoft. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode Plugin — spec-gate.ts
|
|
5
|
+
*
|
|
6
|
+
* Advisory port of .claude/hooks/spec-gate.cjs and .codex/hooks/spec-gate.cjs.
|
|
7
|
+
*
|
|
8
|
+
* OpenCode has no Stop-hook equivalent that can hard-block a turn end.
|
|
9
|
+
* This plugin provides a best-effort advisory gate:
|
|
10
|
+
* - On tool.execute.after for writes to specs/<feature>/spec.json or task markdown, re-validate
|
|
11
|
+
* any newly-done task receipts (Verification: PASS, Command, Exit, provenance, completed_at).
|
|
12
|
+
* - On session.idle (≈ Claude Stop), run the same check and surface a banner + stderr warning.
|
|
13
|
+
*
|
|
14
|
+
* It never throws to break the turn (fail-open). Disable via `.opencode/runtime.json`
|
|
15
|
+
* `{ "spec": { "completion_gate": false } }`. Missing key keeps the advisory gate ON.
|
|
16
|
+
* This is tier-2 compared to Claude/Codex hard gates — docs must not claim parity.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
appendFileSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { join, dirname, relative, resolve, sep, isAbsolute } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const SPEC_GATE_BANNER_START = "<!-- CAFEKIT SPEC-GATE START -->";
|
|
33
|
+
const SPEC_GATE_BANNER_END = "<!-- CAFEKIT SPEC-GATE END -->";
|
|
34
|
+
|
|
35
|
+
type TaskEntry = {
|
|
36
|
+
status?: string;
|
|
37
|
+
completed_at?: string;
|
|
38
|
+
receipt?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type SpecJson = {
|
|
42
|
+
status?: string;
|
|
43
|
+
current_phase?: string;
|
|
44
|
+
phase?: string;
|
|
45
|
+
task_registry?: Record<string, TaskEntry>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const EVIDENCE_NAMES = ["Evidence", "Task Test Plan & Verification Evidence", "Verification & Evidence"];
|
|
49
|
+
const PASS_MARKER = /^\s*Verification:\s*PASS\s*$/m;
|
|
50
|
+
const LEGACY_SUCCESS = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
|
|
51
|
+
const EXPLICIT_FAILURE = /\bFAIL(?:ED|URE|URES|ING)?\b|tests?\s+failed|exit\s+code\s*[:=]?\s*[1-9]\d*|\b0\s+tests?\b/i;
|
|
52
|
+
|
|
53
|
+
function readRuntime(cwd: string): Record<string, any> {
|
|
54
|
+
try {
|
|
55
|
+
const file = join(cwd, ".opencode", "runtime.json");
|
|
56
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function evidenceBody(text: string): string | null {
|
|
63
|
+
const lines = text.split("\n");
|
|
64
|
+
let start = -1;
|
|
65
|
+
let level = 0;
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const m = lines[i].match(/^(#{2,3})\s+(.+?)\s*$/);
|
|
68
|
+
if (m && EVIDENCE_NAMES.includes(m[2])) {
|
|
69
|
+
start = i + 1;
|
|
70
|
+
level = m[1].length;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (start < 0) return null;
|
|
75
|
+
let end = lines.length;
|
|
76
|
+
for (let i = start; i < lines.length; i++) {
|
|
77
|
+
const hm = lines[i].match(/^(#{1,6})\s+/);
|
|
78
|
+
if (hm && hm[1].length <= level) {
|
|
79
|
+
end = i;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return lines.slice(start, end).join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validateCanonicalReceipt(body: string): string[] {
|
|
87
|
+
const fails: string[] = [];
|
|
88
|
+
if (!/^\s*Verification:\s*PASS\s*$/m.test(body)) fails.push("verification_state");
|
|
89
|
+
if (!/^\s*Command(?:\(s\))?\s*:/m.test(body)) fails.push("command");
|
|
90
|
+
if (!/^\s*Exit\s*:|exit\s+code\s*[:=]|\bResult\s*:\s*PASS\b/im.test(body)) fails.push("exit_result");
|
|
91
|
+
const hasBase = /^\s*Base\s*:[ \t]*\S/im.test(body);
|
|
92
|
+
const hasHead = /^\s*Head\s*:[ \t]*\S/im.test(body);
|
|
93
|
+
const hasBaseSha = /\bbase_sha\s*:[ \t]*\S/im.test(body);
|
|
94
|
+
const hasHeadSha = /\bhead_sha\s*:[ \t]*\S/im.test(body);
|
|
95
|
+
if (!((hasBase && hasHead) || (hasBaseSha && hasHeadSha))) fails.push("provenance");
|
|
96
|
+
if (/\bartifact\b/i.test(body) && !/sha256:/i.test(body)) fails.push("artifact_hash");
|
|
97
|
+
return fails;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkReceipt(featureDir: string, taskPath: string, task: TaskEntry): string[] {
|
|
101
|
+
const fails: string[] = [];
|
|
102
|
+
const abs = resolve(join(featureDir, taskPath));
|
|
103
|
+
const resolvedFeatureDir = resolve(featureDir);
|
|
104
|
+
const rel = relative(resolvedFeatureDir, abs);
|
|
105
|
+
// prevent path traversal: taskPath must stay inside featureDir (cross-platform sibling-prefix safe)
|
|
106
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return ["a"];
|
|
107
|
+
if (!existsSync(abs)) return ["a"];
|
|
108
|
+
let text: string;
|
|
109
|
+
try {
|
|
110
|
+
text = readFileSync(abs, "utf8");
|
|
111
|
+
} catch {
|
|
112
|
+
return ["a"];
|
|
113
|
+
}
|
|
114
|
+
const statusLine = text.split("\n").find((l) => /^\s*(?:\*\*)?Status(?:\*\*)?\s*:/i.test(l));
|
|
115
|
+
if (!statusLine || !/\bdone\b/i.test(statusLine)) fails.push("a");
|
|
116
|
+
const body = evidenceBody(text);
|
|
117
|
+
if (body === null) {
|
|
118
|
+
fails.push("b");
|
|
119
|
+
} else if (/\{\{[^}]+\}\}/.test(body) || EXPLICIT_FAILURE.test(body) || !(PASS_MARKER.test(body) || LEGACY_SUCCESS.test(body))) {
|
|
120
|
+
fails.push("c");
|
|
121
|
+
} else {
|
|
122
|
+
const canonical = validateCanonicalReceipt(body);
|
|
123
|
+
const map: Record<string, string> = { verification_state: "c", command: "e", exit_result: "f", provenance: "g", artifact_hash: "h" };
|
|
124
|
+
for (const f of canonical) {
|
|
125
|
+
if (f === "verification_state") {
|
|
126
|
+
if (!PASS_MARKER.test(body) && !fails.includes("c")) fails.push("c");
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const letter = map[f];
|
|
130
|
+
if (letter && !fails.includes(letter)) fails.push(letter);
|
|
131
|
+
}
|
|
132
|
+
if (!PASS_MARKER.test(body) && !fails.includes("c")) fails.push("c");
|
|
133
|
+
}
|
|
134
|
+
const at = task?.completed_at;
|
|
135
|
+
if (typeof at !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(at) || Number.isNaN(Date.parse(at))) fails.push("d");
|
|
136
|
+
return fails;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function findActiveSpec(specsPath: string): { featureName: string; spec: SpecJson; specsDir: string; featureDir: string } | null {
|
|
140
|
+
if (!existsSync(specsPath)) return null;
|
|
141
|
+
let entries;
|
|
142
|
+
try {
|
|
143
|
+
entries = readdirSync(specsPath, { withFileTypes: true });
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (!entry.isDirectory()) continue;
|
|
149
|
+
const specFile = join(specsPath, entry.name, "spec.json");
|
|
150
|
+
if (!existsSync(specFile)) continue;
|
|
151
|
+
try {
|
|
152
|
+
const data = JSON.parse(readFileSync(specFile, "utf8")) as SpecJson;
|
|
153
|
+
if (data.status === "in_progress" || data.status === "in-progress") {
|
|
154
|
+
return { featureName: entry.name, spec: data, specsDir: specsPath, featureDir: join(specsPath, entry.name) };
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// skip bad json
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isStaleFlashDone(task: TaskEntry): boolean {
|
|
164
|
+
return task?.status === "done" && task.receipt === "FLASH_UNVERIFIED";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function collectFailures(cwd: string): { featureName: string; failures: { taskPath: string; fails: string[] }[] } | null {
|
|
168
|
+
const runtime = readRuntime(cwd);
|
|
169
|
+
if (runtime.spec && runtime.spec.completion_gate === false) return null;
|
|
170
|
+
const specsPath = join(cwd, runtime.paths?.specs || "specs");
|
|
171
|
+
const active = findActiveSpec(specsPath);
|
|
172
|
+
if (!active) return null;
|
|
173
|
+
const registry = active.spec.task_registry || {};
|
|
174
|
+
const staleFlash = Object.entries(registry).filter(([, t]) => isStaleFlashDone(t as TaskEntry));
|
|
175
|
+
if (staleFlash.length > 0) {
|
|
176
|
+
return {
|
|
177
|
+
featureName: active.featureName,
|
|
178
|
+
failures: staleFlash.map(([p]) => ({ taskPath: p, fails: ["flash"] })),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// Check every done task (advisory — we have no cache to know newly-done, so check all)
|
|
182
|
+
const failures: { taskPath: string; fails: string[] }[] = [];
|
|
183
|
+
for (const [taskPath, task] of Object.entries(registry)) {
|
|
184
|
+
if ((task as TaskEntry)?.status !== "done") continue;
|
|
185
|
+
const fails = checkReceipt(active.featureDir, taskPath, task as TaskEntry);
|
|
186
|
+
if (fails.length > 0) failures.push({ taskPath, fails });
|
|
187
|
+
}
|
|
188
|
+
if (failures.length === 0) return null;
|
|
189
|
+
return { featureName: active.featureName, failures };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatReason(featureName: string, failures: { taskPath: string; fails: string[] }[]): string {
|
|
193
|
+
const flashOnly = failures.length === 1 && failures[0].fails.includes("flash");
|
|
194
|
+
if (flashOnly) {
|
|
195
|
+
return `Completion gate (advisory): 1 task(s) marked done with FLASH_UNVERIFIED (${failures[0].taskPath}). Run /hapo:test for exact proof, then use explicit sync-finalize.`;
|
|
196
|
+
}
|
|
197
|
+
const lines = [`⚠️ Completion gate (advisory): ${failures.length} done task(s) lack a verification receipt.`];
|
|
198
|
+
for (const { taskPath, fails } of failures) {
|
|
199
|
+
if (fails.includes("flash")) {
|
|
200
|
+
lines.push(`- \`${taskPath}\`: FLASH_UNVERIFIED`);
|
|
201
|
+
} else {
|
|
202
|
+
lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(", ")}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return lines.slice(0, 8).join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function writeBanner(cwd: string, reason: string | null): void {
|
|
209
|
+
try {
|
|
210
|
+
const file = join(cwd, ".opencode", "session-banner.md");
|
|
211
|
+
const dir = dirname(file);
|
|
212
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
213
|
+
let existing = "";
|
|
214
|
+
try {
|
|
215
|
+
existing = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
216
|
+
} catch {
|
|
217
|
+
existing = "";
|
|
218
|
+
}
|
|
219
|
+
const pattern = new RegExp(`${SPEC_GATE_BANNER_START}[\\s\\S]*?${SPEC_GATE_BANNER_END}\\n?`);
|
|
220
|
+
const cleaned = existing.replace(pattern, "").trimEnd();
|
|
221
|
+
if (!reason) {
|
|
222
|
+
if (cleaned !== existing.trimEnd()) writeFileSync(file, cleaned ? `${cleaned}\n` : "");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const block = [SPEC_GATE_BANNER_START, reason, SPEC_GATE_BANNER_END].join("\n");
|
|
226
|
+
const next = cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`;
|
|
227
|
+
writeFileSync(file, next);
|
|
228
|
+
} catch {
|
|
229
|
+
// fail-open
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function logCrash(error: unknown): void {
|
|
234
|
+
try {
|
|
235
|
+
const logDir = join(PLUGIN_DIR, ".logs");
|
|
236
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
237
|
+
appendFileSync(
|
|
238
|
+
join(logDir, "hook-log.jsonl"),
|
|
239
|
+
JSON.stringify({ ts: new Date().toISOString(), hook: "spec-gate", status: "crash", error: error instanceof Error ? error.message : String(error) }) + "\n",
|
|
240
|
+
);
|
|
241
|
+
} catch {
|
|
242
|
+
// fail-open
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const RELEVANT_TOOLS = new Set(["edit", "write", "apply_patch", "task", "taskcreate", "taskupdate", "todowrite"]);
|
|
247
|
+
|
|
248
|
+
export const SpecGate: Plugin = async ({ directory }) => ({
|
|
249
|
+
event: async ({ event }) => {
|
|
250
|
+
try {
|
|
251
|
+
if (event.type === "session.idle") {
|
|
252
|
+
const result = collectFailures(directory);
|
|
253
|
+
if (result) {
|
|
254
|
+
const reason = formatReason(result.featureName, result.failures);
|
|
255
|
+
writeBanner(directory, reason);
|
|
256
|
+
console.error(reason);
|
|
257
|
+
} else {
|
|
258
|
+
writeBanner(directory, null);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
logCrash(error);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
"tool.execute.after": async (input) => {
|
|
266
|
+
try {
|
|
267
|
+
if (!RELEVANT_TOOLS.has(input.tool)) return;
|
|
268
|
+
const result = collectFailures(directory);
|
|
269
|
+
if (result) {
|
|
270
|
+
const reason = formatReason(result.featureName, result.failures);
|
|
271
|
+
writeBanner(directory, reason);
|
|
272
|
+
console.error(`[spec-gate advisory] ${reason}`);
|
|
273
|
+
} else {
|
|
274
|
+
// clear banner if no failures after a relevant tool
|
|
275
|
+
// (keep banner if failures still exist from other tasks — collectFailures already handles)
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
logCrash(error);
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
export default SpecGate;
|