Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
68 changes: 52 additions & 16 deletions .opencode/modules/install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,30 @@ import { parseModulefile, parseFlowList } from '../../tools/lib/parse-modulefile

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const MODULES_DIR = join(ROOT, '.opencode', 'modules');
const INSTALLED_PATH = join(MODULES_DIR, 'installed.json');

// Harness target: 'opencode' (default) or 'pi' (--pi flag)
// Controls where module files are installed and where installed.json lives.
let HARNESS = 'opencode';
let MODULES_DIR = join(ROOT, '.opencode', 'modules');
let INSTALLED_PATH = join(MODULES_DIR, 'installed.json');
let HARNESS_TARGET = '.opencode';
const OPENCODE_JSON = join(ROOT, 'opencode.json');

function setHarness(mode) {
HARNESS = mode;
if (mode === 'pi') {
MODULES_DIR = join(ROOT, '.agents', 'modules');
HARNESS_TARGET = '.agents';
} else {
MODULES_DIR = join(ROOT, '.opencode', 'modules');
HARNESS_TARGET = '.opencode';
}
INSTALLED_PATH = join(MODULES_DIR, 'installed.json');
}

// ── Logging ────────────────────────────────────────────────────────────────

const LOG_PREFIXES = { ADD: 'ADD', DEL: 'DEL', SKIP: 'SKIP', KEEP: 'KEEP', WARN: 'WARN', ERR: 'ERR', OK: 'OK' };
const LOG_PREFIXES = { ADD: 'ADD', DEL: 'DEL', SKIP: 'SKIP', KEEP: 'KEEP', WARN: 'WARN', ERR: 'ERR', OK: 'OK', SAME: 'SAME', UPDATE: 'UPDATE' };

function log(prefix, msg) {
const p = (LOG_PREFIXES[prefix] || prefix).padEnd(5);
Expand Down Expand Up @@ -163,7 +180,7 @@ function cmdAdd(args) {
continue;
}

walkAndCopy(entryPath, join(ROOT, '.opencode', entry), addedFiles, force);
walkAndCopy(entryPath, join(ROOT, HARNESS_TARGET, entry), addedFiles, force);
}

// Handle MCP fragments
Expand All @@ -178,6 +195,13 @@ function cmdAdd(args) {
log('WARN', `MCP fragment '${mcpFile}' is not valid JSON — skipped`);
continue;
}
// MCP fragments are only merged into opencode.json (OpenCode harness).
// Pi harness manages MCP separately — skip with a warning.
if (HARNESS === 'pi') {
log('WARN', `MCP fragment '${mcpFile}' — Pi harness does not merge MCP via installer (skipped)`);
summary.mcpSkipped++;
continue;
}
const ocfg = readJSON(OPENCODE_JSON) || {};
if (!ocfg.mcp) ocfg.mcp = {};
let merged = false;
Expand Down Expand Up @@ -245,14 +269,14 @@ function walkAndCopy(srcDir, destDir, fileList, force = false) {
copyFileSync(srcPath, destPath);
log('UPDATE', `${toForward(relative(MODULES_DIR, srcPath))} → ${toForward(relative(ROOT, destPath))}`);
}
fileList.push(toForward(relative(join(ROOT, '.opencode'), destPath)));
fileList.push(toForward(relative(join(ROOT, HARNESS_TARGET), destPath)));
} else {
log('SKIP', `${toForward(relative(MODULES_DIR, srcPath))} → ${toForward(relative(ROOT, destPath))}`);
}
} else {
copyFileSync(srcPath, destPath);
log('ADD', `${toForward(relative(MODULES_DIR, srcPath))} → ${toForward(relative(ROOT, destPath))}`);
fileList.push(toForward(relative(join(ROOT, '.opencode'), destPath)));
fileList.push(toForward(relative(join(ROOT, HARNESS_TARGET), destPath)));
}
}
}
Expand Down Expand Up @@ -304,7 +328,7 @@ function cmdRemove(name) {

// Remove tracked files
for (const f of files) {
const installedPath = join(ROOT, '.opencode', f);
const installedPath = join(ROOT, HARNESS_TARGET, f);
const sourcePath = join(MODULES_DIR, name, f);

if (!existsSync(installedPath)) {
Expand Down Expand Up @@ -332,8 +356,8 @@ function cmdRemove(name) {
// Clean up empty parent directories
const parentDirs = new Set();
for (const f of files) {
let dir = dirname(join(ROOT, '.opencode', f));
while (dir.startsWith(join(ROOT, '.opencode'))) {
let dir = dirname(join(ROOT, HARNESS_TARGET, f));
while (dir.startsWith(join(ROOT, HARNESS_TARGET))) {
parentDirs.add(dir);
dir = dirname(dir);
}
Expand All @@ -349,8 +373,8 @@ function cmdRemove(name) {
} catch { /* skip */ }
}

// Remove MCP servers from opencode.json
if (mcpServers.length > 0) {
// Remove MCP servers from opencode.json (OpenCode-only)
if (mcpServers.length > 0 && HARNESS !== 'pi') {
const ocfg = readJSON(OPENCODE_JSON);
if (ocfg && ocfg.mcp) {
for (const serverName of mcpServers) {
Expand Down Expand Up @@ -386,7 +410,7 @@ function runValidation() {
return;
}
log('OK', 'Running post-install validation...');
const result = spawnSync('node', [validatePath], { cwd: ROOT, stdio: 'inherit', shell: true });
const result = spawnSync('node', [validatePath], { cwd: ROOT, stdio: 'inherit' });
if (result.status !== 0) {
log('WARN', `Validation exited with code ${result.status} — fix issues if needed`);
} else {
Expand All @@ -398,12 +422,24 @@ function runValidation() {

function main() {
const args = process.argv.slice(2);

// Parse --pi flag (can appear anywhere in args)
const piIndex = args.indexOf('--pi');
if (piIndex !== -1) {
setHarness('pi');
args.splice(piIndex, 1);
}

if (args.length === 0) {
const scriptPath = HARNESS === 'pi' ? '.agents/modules/install.mjs' : '.opencode/modules/install.mjs';
console.log('Usage:');
console.log(' node .opencode/modules/install.mjs list');
console.log(' node .opencode/modules/install.mjs info <name>');
console.log(' node .opencode/modules/install.mjs add <name...>');
console.log(' node .opencode/modules/install.mjs remove <name>');
console.log(` node ${scriptPath} list [--pi]`);
console.log(` node ${scriptPath} info <name> [--pi]`);
console.log(` node ${scriptPath} add <name...> [--pi]`);
console.log(` node ${scriptPath} remove <name> [--pi]`);
console.log('');
console.log('Options:');
console.log(' --pi Install to .agents/ instead of .opencode/ (Pi harness)');
process.exit(0);
}

Expand Down
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
Loading
Loading