Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 2 additions & 11 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,5 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 20
- run: node .opencode/plugins/tests/test-session-start.mjs
- run: node .opencode/plugins/tests/test-session-stop.mjs
- run: node .opencode/plugins/tests/test-detect-gaps.mjs
- run: node .opencode/plugins/tests/test-validate-assets.mjs
- run: node .opencode/plugins/tests/test-validate-commit.mjs
- run: node .opencode/plugins/tests/test-validate-push.mjs
- run: node .opencode/plugins/tests/test-validate-skill-change.mjs
- run: node .opencode/plugins/tests/test-pre-compact.mjs
- run: node .opencode/plugins/tests/test-post-compact.mjs
- run: node .opencode/plugins/tests/test-log-agent.mjs
- run: node .opencode/plugins/tests/test-log-agent-stop.mjs
- run: npm install
- run: npm test
66 changes: 39 additions & 27 deletions .opencode/plugins/ccgs-hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Plugin } from "@opencode-ai/plugin"
import { execSync } from "child_process"
import { execFileSync, execSync, spawnSync } from "child_process"
import * as fs from "fs"
import * as path from "path"

Expand All @@ -10,7 +10,7 @@ import * as path from "path"

const PROTECTED_BRANCHES = ["main", "master", "develop"]
const SOURCE_EXTENSIONS = [".gd", ".cs", ".cpp", ".c", ".h", ".hpp", ".rs", ".py", ".js", ".ts"]
const DESIGN_SECTIONS = [
export const DESIGN_SECTIONS = [
"Overview",
"Player Fantasy",
"Detailed",
Expand All @@ -23,20 +23,17 @@ const DESIGN_SECTIONS = [

function isGitRepo(cwd: string): boolean {
try {
execSync("git rev-parse --git-dir", { encoding: "utf8", cwd, stdio: "ignore" })
execSync("git rev-parse --git-dir", { encoding: "utf8", cwd, stdio: "ignore", timeout: 5000 })
return true
} catch {
return false
}
}

function git(cwd: string, ...args: string[]): string {
try {
const cmd = args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")
return execSync(`git ${cmd}`, { encoding: "utf8", cwd, stdio: ["pipe", "pipe", "ignore"] }).trim()
} catch {
return ""
}
const result = spawnSync("git", args, { encoding: "utf8", cwd, stdio: ["pipe", "pipe", "ignore"], timeout: 15000 })
if (result.error || result.status !== 0) return ""
return result.stdout.trim()
}

function normalizePath(p: string): string {
Expand Down Expand Up @@ -318,7 +315,7 @@ export function handleDetectGaps(projectRoot: string): string[] {

if (srcCount > 100) {
if (!fs.existsSync(path.join(projectRoot, "production", "sprints")) &&
!fs.existsSync(path.join(projectRoot, "production", "milestones"))) {
!fs.existsSync(path.join(projectRoot, "production", "milestones"))) {
add(`GAP: ${srcCount} files but no production planning. Run: /sprint-plan`)
}
}
Expand Down Expand Up @@ -378,6 +375,7 @@ export function detectSkillChange(filePath: string): string | null {
}

export function validateAssetPath(projectRoot: string, filePath: string): { warnings: string[]; errors: string[] } {
filePath = normalizePath(filePath)
const warnings: string[] = []
const errors: string[] = []

Expand All @@ -402,7 +400,7 @@ export function validateAssetPath(projectRoot: string, filePath: string): { warn

function runGit(cwd: string, cmd: string): string {
try {
return execSync(cmd, { encoding: "utf8", cwd, stdio: ["pipe", "pipe", "ignore"] }).trim()
return execSync(cmd, { encoding: "utf8", cwd, stdio: ["pipe", "pipe", "ignore"], timeout: 15000 }).trim()
} catch {
return ""
}
Expand Down Expand Up @@ -498,7 +496,7 @@ export function buildCompactionContext(projectRoot: string): string {
return lines.join("\n")
}

function logCompactionEvent(projectRoot: string) {
export function logCompactionEvent(projectRoot: string) {
try {
const logDir = path.join(projectRoot, "production", "session-logs")
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true })
Expand All @@ -521,21 +519,35 @@ export function handlePostCompact(projectRoot: string): string {

export function showNotification(message: string) {
const text = (message || "Claude Code needs your attention").slice(0, 200)
const platform = process.platform

try {
execSync(
`powershell.exe -NonInteractive -WindowStyle Hidden -Command "` +
`Add-Type -AssemblyName System.Windows.Forms; ` +
`$n = New-Object System.Windows.Forms.NotifyIcon; ` +
`$n.Icon = [System.Drawing.SystemIcons]::Information; ` +
`$n.BalloonTipTitle = 'Claude Code'; ` +
`$n.BalloonTipText = '${text.replace(/'/g, "''")}'; ` +
`$n.Visible = $true; ` +
`$n.ShowBalloonTip(5000); ` +
`Start-Sleep -Seconds 6; ` +
`$n.Dispose()"`,
{ stdio: "ignore", timeout: 10000 }
)
} catch { /* ignore */ }
if (platform === "win32") {
execSync(
`powershell.exe -NonInteractive -WindowStyle Hidden -Command "` +
`Add-Type -AssemblyName System.Windows.Forms; ` +
`$n = New-Object System.Windows.Forms.NotifyIcon; ` +
`$n.Icon = [System.Drawing.SystemIcons]::Information; ` +
`$n.BalloonTipTitle = 'OpenCode'; ` +
`$n.BalloonTipText = '${text.replace(/'/g, "''")}'; ` +
`$n.Visible = $true; ` +
`$n.ShowBalloonTip(5000); ` +
`Start-Sleep -Seconds 6; ` +
`$n.Dispose()"`,
{ stdio: "ignore", timeout: 10000 }
)
} else if (platform === "darwin") {
execFileSync("osascript", ["-e", `display notification ${JSON.stringify(text)} with title "OpenCode"`], {
stdio: "ignore",
timeout: 5000,
})
} else if (platform === "linux") {
execFileSync("notify-send", ["OpenCode", text], {
stdio: "ignore",
timeout: 5000,
})
}
} catch { /* ignore — notification is best-effort */ }
}

export function detectPushToProtected(cmd: string, currentBranch: string): string {
Expand All @@ -550,7 +562,7 @@ export function detectPushToProtected(cmd: string, currentBranch: string): strin
type PluginLogger = ReturnType<typeof createPluginLogger>
function createPluginLogger(client: any, service: string) {
const log = (level: string, message: string, extra?: any) => {
client.app.log({ body: { service, level, message, extra } }).catch(() => {})
client.app.log({ body: { service, level, message, extra } }).catch(() => { })
}
return { debug: (m: string, x?: any) => log("debug", m, x), info: (m: string, x?: any) => log("info", m, x), warn: (m: string, x?: any) => log("warn", m, x), error: (m: string, x?: any) => log("error", m, x) }
}
Expand Down
29 changes: 20 additions & 9 deletions .opencode/plugins/changelog-generator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Plugin } from "@opencode-ai/plugin"
import { execSync } from "child_process"
import { execSync, spawnSync } from "child_process"
import * as fs from "fs"
import * as path from "path"

Expand Down Expand Up @@ -31,7 +31,8 @@ const TYPE_CATEGORIES = ["feat", "fix", "perf", "refactor", "revert", "docs", "t

function git(projectRoot: string, args: string[]): string {
try {
return execSync(`git ${args.join(" ")}`, { encoding: "utf8", cwd: projectRoot, stdio: ["pipe", "pipe", "ignore"] }).trim()
const result = spawnSync("git", args, { encoding: "utf8", cwd: projectRoot, stdio: ["pipe", "pipe", "ignore"], timeout: 15000 })
return result.stdout.trim()
} catch {
return ""
}
Expand All @@ -42,7 +43,7 @@ function getLastTag(projectRoot: string): string {
return tag || "initial"
}

function parseConventionalCommits(projectRoot: string, sinceTag: string): CommitEntry[] {
export function parseConventionalCommits(projectRoot: string, sinceTag: string): CommitEntry[] {
const range = sinceTag === "initial"
? "HEAD"
: `${sinceTag}..HEAD`
Expand Down Expand Up @@ -95,7 +96,7 @@ function parseConventionalCommits(projectRoot: string, sinceTag: string): Commit
return entries
}

function generateInternalChangelog(entries: CommitEntry[], version: string, date: string): string {
export function generateInternalChangelog(entries: CommitEntry[], version: string, date: string): string {
const lines: string[] = []
lines.push(`# Changelog`)
lines.push(``)
Expand Down Expand Up @@ -132,7 +133,7 @@ function generateInternalChangelog(entries: CommitEntry[], version: string, date
return lines.join("\n")
}

function generatePlayerChangelog(entries: CommitEntry[], version: string, date: string): string {
export function generatePlayerChangelog(entries: CommitEntry[], version: string, date: string): string {
const lines: string[] = []
lines.push(`# Update ${version} — ${date}`)
lines.push(``)
Expand Down Expand Up @@ -161,7 +162,7 @@ function generatePlayerChangelog(entries: CommitEntry[], version: string, date:
return lines.join("\n")
}

function updateChangelogFile(projectRoot: string, version: string, content: string, isPlayerFacing: boolean) {
export function updateChangelogFile(projectRoot: string, version: string, content: string, isPlayerFacing: boolean) {
const filename = isPlayerFacing ? "CHANGELOG.md" : "CHANGELOG_INTERNAL.md"
const filePath = path.join(projectRoot, filename)

Expand All @@ -170,6 +171,14 @@ function updateChangelogFile(projectRoot: string, version: string, content: stri
existing = fs.readFileSync(filePath, "utf8")
}

// Guard against duplicate prepends: if the existing file already starts with
// the same content (handles repeated session.idle with no new commits).
const contentBody = content.replace(/^# Changelog\n\n/m, "").trim()
const existingBody = existing.replace(/^# Changelog\n\n/m, "").trim()
if (existingBody && existingBody.startsWith(contentBody)) {
return
}

// Prepend new version content, keep existing below
const updated = existing
? content + "\n\n" + existing.replace(/^# Changelog\n\n/m, "") + "\n"
Expand All @@ -181,7 +190,7 @@ function updateChangelogFile(projectRoot: string, version: string, content: stri
type PluginLogger = ReturnType<typeof createPluginLogger>
function createPluginLogger(client: any, service: string) {
const log = (level: string, message: string, extra?: any) => {
client?.app?.log({ body: { service, level, message, extra } }).catch(() => {})
client?.app?.log({ body: { service, level, message, extra } }).catch(() => { })
}
return {
debug: (m: string, x?: any) => log("debug", m, x),
Expand Down Expand Up @@ -223,10 +232,12 @@ export const ChangelogGenerator: Plugin = async ({ project, client, directory, w
try {
const { internal, player } = generateChangelogs(projectRoot, "unreleased")
if (!internal.includes("No changes")) {
logger.info("Changelog generated with unreleased changes — run changelog-generator to write CHANGELOG.md.")
updateChangelogFile(projectRoot, "unreleased", internal, false)
updateChangelogFile(projectRoot, "unreleased", player, true)
logger.info("Changelog written to CHANGELOG.md and CHANGELOG_INTERNAL.md")
}
} catch (err) {
logger.error("Failed to generate changelog preview", { error: String(err) })
logger.error("Failed to generate changelog", { error: String(err) })
}
}
},
Expand Down
8 changes: 4 additions & 4 deletions .opencode/plugins/drift-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const SKILL_RECOMMENDED_SECTIONS = [
"Next Steps",
]

function parseFrontmatter(content: string): Record<string, string> | null {
export function parseFrontmatter(content: string): Record<string, string> | null {
const match = content.match(/^---\n([\s\S]*?)\n---/)
if (!match) return null

Expand All @@ -51,7 +51,7 @@ function parseFrontmatter(content: string): Record<string, string> | null {
return data
}

function detectAgentDrift(projectRoot: string, filePath: string): DriftIssue[] {
export function detectAgentDrift(projectRoot: string, filePath: string): DriftIssue[] {
const issues: DriftIssue[] = []

if (!filePath.startsWith(".agents/agents/") || !filePath.endsWith(".md")) return issues
Expand Down Expand Up @@ -136,7 +136,7 @@ function detectAgentDrift(projectRoot: string, filePath: string): DriftIssue[] {
return issues
}

function detectSkillDrift(projectRoot: string, filePath: string): DriftIssue[] {
export function detectSkillDrift(projectRoot: string, filePath: string): DriftIssue[] {
const issues: DriftIssue[] = []

if (!filePath.startsWith(".agents/skills/") || !filePath.endsWith("SKILL.md")) return issues
Expand Down Expand Up @@ -212,7 +212,7 @@ function detectSkillDrift(projectRoot: string, filePath: string): DriftIssue[] {
return issues
}

function detectCommandDrift(projectRoot: string, filePath: string): DriftIssue[] {
export function detectCommandDrift(projectRoot: string, filePath: string): DriftIssue[] {
const issues: DriftIssue[] = []

if (!filePath.startsWith(".agents/commands/") || !filePath.endsWith(".md")) return issues
Expand Down
Loading
Loading