@kodrunhq/opencode-autopilot 1.3.0 → 1.5.0
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/assets/commands/brainstorm.md +7 -0
- package/assets/commands/stocktake.md +7 -0
- package/assets/commands/tdd.md +7 -0
- package/assets/commands/update-docs.md +7 -0
- package/assets/commands/write-plan.md +7 -0
- package/assets/skills/brainstorming/SKILL.md +295 -0
- package/assets/skills/code-review/SKILL.md +241 -0
- package/assets/skills/e2e-testing/SKILL.md +266 -0
- package/assets/skills/git-worktrees/SKILL.md +296 -0
- package/assets/skills/go-patterns/SKILL.md +240 -0
- package/assets/skills/plan-executing/SKILL.md +258 -0
- package/assets/skills/plan-writing/SKILL.md +278 -0
- package/assets/skills/python-patterns/SKILL.md +255 -0
- package/assets/skills/rust-patterns/SKILL.md +293 -0
- package/assets/skills/strategic-compaction/SKILL.md +217 -0
- package/assets/skills/systematic-debugging/SKILL.md +299 -0
- package/assets/skills/tdd-workflow/SKILL.md +311 -0
- package/assets/skills/typescript-patterns/SKILL.md +278 -0
- package/assets/skills/verification/SKILL.md +240 -0
- package/package.json +1 -1
- package/src/index.ts +72 -1
- package/src/observability/context-monitor.ts +102 -0
- package/src/observability/event-emitter.ts +136 -0
- package/src/observability/event-handlers.ts +322 -0
- package/src/observability/event-store.ts +226 -0
- package/src/observability/index.ts +53 -0
- package/src/observability/log-reader.ts +152 -0
- package/src/observability/log-writer.ts +93 -0
- package/src/observability/mock/mock-provider.ts +72 -0
- package/src/observability/mock/types.ts +31 -0
- package/src/observability/retention.ts +57 -0
- package/src/observability/schemas.ts +83 -0
- package/src/observability/session-logger.ts +63 -0
- package/src/observability/summary-generator.ts +209 -0
- package/src/observability/token-tracker.ts +97 -0
- package/src/observability/types.ts +24 -0
- package/src/orchestrator/skill-injection.ts +38 -0
- package/src/review/sanitize.ts +1 -1
- package/src/skills/adaptive-injector.ts +122 -0
- package/src/skills/dependency-resolver.ts +88 -0
- package/src/skills/linter.ts +113 -0
- package/src/skills/loader.ts +88 -0
- package/src/templates/skill-template.ts +4 -0
- package/src/tools/create-skill.ts +12 -0
- package/src/tools/logs.ts +178 -0
- package/src/tools/mock-fallback.ts +100 -0
- package/src/tools/pipeline-report.ts +148 -0
- package/src/tools/session-stats.ts +185 -0
- package/src/tools/stocktake.ts +170 -0
- package/src/tools/update-docs.ts +116 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oc_pipeline_report tool - Decision trace with phase-by-phase breakdown.
|
|
3
|
+
*
|
|
4
|
+
* Reads a session log and produces a read-only report showing:
|
|
5
|
+
* - Phase-by-phase decision timeline
|
|
6
|
+
* - Per-phase decision count
|
|
7
|
+
* - Decisions with agent and rationale context
|
|
8
|
+
*
|
|
9
|
+
* Follows the *Core + tool() wrapper pattern per CLAUDE.md.
|
|
10
|
+
* Returns JSON with displayText field following oc_doctor pattern.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { tool } from "@opencode-ai/plugin";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { readLatestSessionLog, readSessionLog } from "../observability/log-reader";
|
|
18
|
+
import { computeDuration, formatDuration } from "../observability/summary-generator";
|
|
19
|
+
import type { SessionLog } from "../observability/types";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A decision entry within a phase section of the report.
|
|
23
|
+
*/
|
|
24
|
+
interface ReportDecision {
|
|
25
|
+
readonly agent: string;
|
|
26
|
+
readonly decision: string;
|
|
27
|
+
readonly rationale: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A phase section in the pipeline report.
|
|
32
|
+
*/
|
|
33
|
+
interface ReportPhase {
|
|
34
|
+
readonly phase: string;
|
|
35
|
+
readonly decisions: readonly ReportDecision[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Groups decisions by phase, preserving insertion order.
|
|
40
|
+
*/
|
|
41
|
+
function groupDecisionsByPhase(log: SessionLog): readonly ReportPhase[] {
|
|
42
|
+
const phaseOrder: string[] = [];
|
|
43
|
+
const phaseMap = new Map<string, ReportDecision[]>();
|
|
44
|
+
|
|
45
|
+
for (const d of log.decisions) {
|
|
46
|
+
if (!phaseMap.has(d.phase)) {
|
|
47
|
+
phaseOrder.push(d.phase);
|
|
48
|
+
phaseMap.set(d.phase, []);
|
|
49
|
+
}
|
|
50
|
+
const decisions = phaseMap.get(d.phase);
|
|
51
|
+
decisions?.push({
|
|
52
|
+
agent: d.agent,
|
|
53
|
+
decision: d.decision,
|
|
54
|
+
rationale: d.rationale,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return phaseOrder.map((phase) => ({
|
|
59
|
+
phase,
|
|
60
|
+
decisions: phaseMap.get(phase) ?? [],
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Builds the displayText report for the pipeline report.
|
|
66
|
+
*/
|
|
67
|
+
function buildDisplayText(
|
|
68
|
+
log: SessionLog,
|
|
69
|
+
phases: readonly ReportPhase[],
|
|
70
|
+
durationMs: number,
|
|
71
|
+
): string {
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
|
|
74
|
+
// Header
|
|
75
|
+
const durationStr = log.endedAt ? formatDuration(durationMs) : "In progress";
|
|
76
|
+
lines.push(`Pipeline Report: ${log.sessionId}`);
|
|
77
|
+
lines.push(`Duration: ${durationStr}`);
|
|
78
|
+
lines.push(`Total Decisions: ${log.decisions.length}`);
|
|
79
|
+
lines.push("");
|
|
80
|
+
|
|
81
|
+
if (phases.length === 0) {
|
|
82
|
+
lines.push("No decisions recorded in this session.");
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Phase-by-phase breakdown
|
|
87
|
+
for (const phase of phases) {
|
|
88
|
+
lines.push(`--- ${phase.phase} (${phase.decisions.length} decision(s)) ---`);
|
|
89
|
+
for (const d of phase.decisions) {
|
|
90
|
+
lines.push(` [${d.agent}] ${d.decision}`);
|
|
91
|
+
lines.push(` Rationale: ${d.rationale}`);
|
|
92
|
+
}
|
|
93
|
+
lines.push("");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Core function for the oc_pipeline_report tool.
|
|
101
|
+
*
|
|
102
|
+
* @param sessionID - Optional session ID (uses latest if omitted)
|
|
103
|
+
* @param logsDir - Optional override for logs directory (for testing)
|
|
104
|
+
*/
|
|
105
|
+
export async function pipelineReportCore(sessionID?: string, logsDir?: string): Promise<string> {
|
|
106
|
+
const log = sessionID
|
|
107
|
+
? await readSessionLog(sessionID, logsDir)
|
|
108
|
+
: await readLatestSessionLog(logsDir);
|
|
109
|
+
|
|
110
|
+
if (!log) {
|
|
111
|
+
const target = sessionID ? `Session "${sessionID}" not found.` : "No session logs found.";
|
|
112
|
+
return JSON.stringify({
|
|
113
|
+
action: "error",
|
|
114
|
+
message: target,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const durationMs = computeDuration(log);
|
|
119
|
+
const phases = groupDecisionsByPhase(log);
|
|
120
|
+
const totalDecisions = log.decisions.length;
|
|
121
|
+
const displayText = buildDisplayText(log, phases, durationMs);
|
|
122
|
+
|
|
123
|
+
return JSON.stringify({
|
|
124
|
+
action: "pipeline_report",
|
|
125
|
+
sessionId: log.sessionId,
|
|
126
|
+
phases,
|
|
127
|
+
totalDecisions,
|
|
128
|
+
displayText,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Tool wrapper ---
|
|
133
|
+
|
|
134
|
+
export const ocPipelineReport = tool({
|
|
135
|
+
description:
|
|
136
|
+
"View pipeline decision trace. Shows phase-by-phase breakdown of all autonomous decisions " +
|
|
137
|
+
"with agent and rationale. Read-only report for post-session analysis.",
|
|
138
|
+
args: {
|
|
139
|
+
sessionID: z
|
|
140
|
+
.string()
|
|
141
|
+
.regex(/^[a-zA-Z0-9_-]{1,256}$/)
|
|
142
|
+
.optional()
|
|
143
|
+
.describe("Session ID to view (uses latest if omitted)"),
|
|
144
|
+
},
|
|
145
|
+
async execute({ sessionID }) {
|
|
146
|
+
return pipelineReportCore(sessionID);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oc_session_stats tool - Event counts, decisions, errors, and per-phase breakdown.
|
|
3
|
+
*
|
|
4
|
+
* Reads a session log and computes:
|
|
5
|
+
* - Event count totals
|
|
6
|
+
* - Decision count and per-phase grouping
|
|
7
|
+
* - Error summary by type with per-phase attribution
|
|
8
|
+
* - Session duration
|
|
9
|
+
* - Per-phase breakdown (when decisions span multiple phases)
|
|
10
|
+
*
|
|
11
|
+
* Follows the *Core + tool() wrapper pattern per CLAUDE.md.
|
|
12
|
+
* Returns JSON with displayText field following oc_doctor pattern.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { tool } from "@opencode-ai/plugin";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { readLatestSessionLog, readSessionLog } from "../observability/log-reader";
|
|
20
|
+
import { computeDuration, formatDuration } from "../observability/summary-generator";
|
|
21
|
+
import type { SessionLog } from "../observability/types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Per-phase breakdown entry.
|
|
25
|
+
*/
|
|
26
|
+
interface PhaseBreakdownEntry {
|
|
27
|
+
readonly phase: string;
|
|
28
|
+
readonly decisionCount: number;
|
|
29
|
+
readonly errorCount: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Computes per-phase breakdown from session log decisions and events.
|
|
34
|
+
* Groups decisions by phase and counts errors per phase time window.
|
|
35
|
+
*
|
|
36
|
+
* Error-to-phase mapping: for each error event, find the phase whose
|
|
37
|
+
* decision time window (first to last decision timestamp) contains the
|
|
38
|
+
* error timestamp. Unmatched errors are not attributed to any phase
|
|
39
|
+
* (they still appear in the overall errorSummary).
|
|
40
|
+
*/
|
|
41
|
+
function computePhaseBreakdown(log: SessionLog): readonly PhaseBreakdownEntry[] {
|
|
42
|
+
const phaseMap = new Map<string, { decisions: number; errors: number }>();
|
|
43
|
+
|
|
44
|
+
// Collect per-phase time windows from decisions
|
|
45
|
+
const phaseWindows = new Map<string, { start: string; end: string }>();
|
|
46
|
+
|
|
47
|
+
for (const d of log.decisions) {
|
|
48
|
+
const existing = phaseMap.get(d.phase) ?? { decisions: 0, errors: 0 };
|
|
49
|
+
phaseMap.set(d.phase, { ...existing, decisions: existing.decisions + 1 });
|
|
50
|
+
|
|
51
|
+
const ts = d.timestamp ?? "";
|
|
52
|
+
if (!ts) continue; // Skip decisions without timestamps — cannot build time windows
|
|
53
|
+
const window = phaseWindows.get(d.phase);
|
|
54
|
+
if (!window) {
|
|
55
|
+
phaseWindows.set(d.phase, { start: ts, end: ts });
|
|
56
|
+
} else {
|
|
57
|
+
if (ts < window.start) phaseWindows.set(d.phase, { ...window, start: ts });
|
|
58
|
+
if (ts > window.end) phaseWindows.set(d.phase, { ...window, end: ts });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Map errors to phases by timestamp overlap with phase time windows
|
|
63
|
+
for (const e of log.events) {
|
|
64
|
+
if (e.type === "error") {
|
|
65
|
+
for (const [phase, window] of phaseWindows) {
|
|
66
|
+
if (e.timestamp >= window.start && e.timestamp <= window.end) {
|
|
67
|
+
const data = phaseMap.get(phase);
|
|
68
|
+
if (data) {
|
|
69
|
+
phaseMap.set(phase, { ...data, errors: data.errors + 1 });
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result: PhaseBreakdownEntry[] = [];
|
|
78
|
+
for (const [phase, data] of phaseMap) {
|
|
79
|
+
result.push({
|
|
80
|
+
phase,
|
|
81
|
+
decisionCount: data.decisions,
|
|
82
|
+
errorCount: data.errors,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Builds the displayText report for session stats.
|
|
91
|
+
*/
|
|
92
|
+
function buildDisplayText(
|
|
93
|
+
log: SessionLog,
|
|
94
|
+
durationMs: number,
|
|
95
|
+
phaseBreakdown: readonly PhaseBreakdownEntry[],
|
|
96
|
+
): string {
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
|
|
99
|
+
// Header
|
|
100
|
+
lines.push(`Session Stats: ${log.sessionId}`);
|
|
101
|
+
lines.push("");
|
|
102
|
+
|
|
103
|
+
// Duration
|
|
104
|
+
const durationStr = log.endedAt ? formatDuration(durationMs) : "In progress";
|
|
105
|
+
lines.push(`Duration: ${durationStr}`);
|
|
106
|
+
lines.push(`Events: ${log.events.length}`);
|
|
107
|
+
lines.push(`Decisions: ${log.decisions.length}`);
|
|
108
|
+
lines.push("");
|
|
109
|
+
|
|
110
|
+
// Error summary
|
|
111
|
+
const errorEntries = Object.entries(log.errorSummary);
|
|
112
|
+
if (errorEntries.length > 0) {
|
|
113
|
+
lines.push("Error Summary:");
|
|
114
|
+
for (const [type, count] of errorEntries) {
|
|
115
|
+
lines.push(` ${type}: ${count}`);
|
|
116
|
+
}
|
|
117
|
+
lines.push("");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Per-phase breakdown (when phases exist)
|
|
121
|
+
if (phaseBreakdown.length > 0) {
|
|
122
|
+
lines.push("Phase Breakdown:");
|
|
123
|
+
lines.push("| Phase | Decisions | Errors |");
|
|
124
|
+
lines.push("|-------|-----------|--------|");
|
|
125
|
+
for (const p of phaseBreakdown) {
|
|
126
|
+
lines.push(`| ${p.phase} | ${p.decisionCount} | ${p.errorCount} |`);
|
|
127
|
+
}
|
|
128
|
+
lines.push("");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Core function for the oc_session_stats tool.
|
|
136
|
+
*
|
|
137
|
+
* @param sessionID - Optional session ID (uses latest if omitted)
|
|
138
|
+
* @param logsDir - Optional override for logs directory (for testing)
|
|
139
|
+
*/
|
|
140
|
+
export async function sessionStatsCore(sessionID?: string, logsDir?: string): Promise<string> {
|
|
141
|
+
const log = sessionID
|
|
142
|
+
? await readSessionLog(sessionID, logsDir)
|
|
143
|
+
: await readLatestSessionLog(logsDir);
|
|
144
|
+
|
|
145
|
+
if (!log) {
|
|
146
|
+
const target = sessionID ? `Session "${sessionID}" not found.` : "No session logs found.";
|
|
147
|
+
return JSON.stringify({
|
|
148
|
+
action: "error",
|
|
149
|
+
message: target,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const durationMs = computeDuration(log);
|
|
154
|
+
const phaseBreakdown = computePhaseBreakdown(log);
|
|
155
|
+
const displayText = buildDisplayText(log, durationMs, phaseBreakdown);
|
|
156
|
+
|
|
157
|
+
return JSON.stringify({
|
|
158
|
+
action: "session_stats",
|
|
159
|
+
sessionId: log.sessionId,
|
|
160
|
+
duration: durationMs,
|
|
161
|
+
eventCount: log.events.length,
|
|
162
|
+
decisionCount: log.decisions.length,
|
|
163
|
+
errorSummary: log.errorSummary,
|
|
164
|
+
phaseBreakdown,
|
|
165
|
+
displayText,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Tool wrapper ---
|
|
170
|
+
|
|
171
|
+
export const ocSessionStats = tool({
|
|
172
|
+
description:
|
|
173
|
+
"View session statistics including event counts, decisions, errors, and per-phase breakdown. " +
|
|
174
|
+
"Shows duration, error summary, and phase-by-phase activity.",
|
|
175
|
+
args: {
|
|
176
|
+
sessionID: z
|
|
177
|
+
.string()
|
|
178
|
+
.regex(/^[a-zA-Z0-9_-]{1,256}$/)
|
|
179
|
+
.optional()
|
|
180
|
+
.describe("Session ID to view (uses latest if omitted)"),
|
|
181
|
+
},
|
|
182
|
+
async execute({ sessionID }) {
|
|
183
|
+
return sessionStatsCore(sessionID);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tool } from "@opencode-ai/plugin";
|
|
4
|
+
import { lintAgent, lintCommand, lintSkill } from "../skills/linter";
|
|
5
|
+
import { getAssetsDir, getGlobalConfigDir } from "../utils/paths";
|
|
6
|
+
|
|
7
|
+
interface StocktakeArgs {
|
|
8
|
+
readonly lint?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AssetEntry {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly type: "skill" | "command" | "agent";
|
|
14
|
+
readonly origin: "built-in" | "user-created";
|
|
15
|
+
readonly lint?: {
|
|
16
|
+
readonly valid: boolean;
|
|
17
|
+
readonly errors: readonly string[];
|
|
18
|
+
readonly warnings: readonly string[];
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read directory entries safely, returning empty array on ENOENT only. */
|
|
23
|
+
async function safeReaddir(dirPath: string): Promise<string[]> {
|
|
24
|
+
try {
|
|
25
|
+
return await readdir(dirPath);
|
|
26
|
+
} catch (error: unknown) {
|
|
27
|
+
const errObj = error as { code?: unknown };
|
|
28
|
+
if (errObj?.code === "ENOENT") return [];
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Cache for built-in asset lookups (keyed by assetType). */
|
|
34
|
+
const builtInCache = new Map<string, ReadonlySet<string>>();
|
|
35
|
+
|
|
36
|
+
/** Check if an asset name exists in the bundled assets directory. */
|
|
37
|
+
async function isBuiltIn(assetType: string, name: string): Promise<boolean> {
|
|
38
|
+
let cached = builtInCache.get(assetType);
|
|
39
|
+
if (!cached) {
|
|
40
|
+
const assetsDir = getAssetsDir();
|
|
41
|
+
const entries = await safeReaddir(join(assetsDir, assetType));
|
|
42
|
+
cached = new Set(entries);
|
|
43
|
+
builtInCache.set(assetType, cached);
|
|
44
|
+
}
|
|
45
|
+
return cached.has(name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function stocktakeCore(args: StocktakeArgs, baseDir: string): Promise<string> {
|
|
49
|
+
const shouldLint = args.lint !== false;
|
|
50
|
+
const skills: AssetEntry[] = [];
|
|
51
|
+
const commands: AssetEntry[] = [];
|
|
52
|
+
const agents: AssetEntry[] = [];
|
|
53
|
+
|
|
54
|
+
// Scan skills (each subdirectory is a skill) — filter to directories only
|
|
55
|
+
const skillEntries = await readdir(join(baseDir, "skills"), { withFileTypes: true }).catch(
|
|
56
|
+
() => [],
|
|
57
|
+
);
|
|
58
|
+
const skillDirs = skillEntries
|
|
59
|
+
.filter((e) => e.isDirectory() && e.name !== ".gitkeep")
|
|
60
|
+
.map((e) => e.name);
|
|
61
|
+
for (const name of skillDirs) {
|
|
62
|
+
const skillFile = join(baseDir, "skills", name, "SKILL.md");
|
|
63
|
+
const origin = (await isBuiltIn("skills", name)) ? "built-in" : "user-created";
|
|
64
|
+
const entry: AssetEntry = { name, type: "skill", origin };
|
|
65
|
+
|
|
66
|
+
if (shouldLint) {
|
|
67
|
+
try {
|
|
68
|
+
const content = await readFile(skillFile, "utf-8");
|
|
69
|
+
const lint = lintSkill(content);
|
|
70
|
+
skills.push({ ...entry, lint });
|
|
71
|
+
} catch {
|
|
72
|
+
skills.push({
|
|
73
|
+
...entry,
|
|
74
|
+
lint: { valid: false, errors: ["Could not read SKILL.md"], warnings: [] },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
skills.push(entry);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Scan commands (.md files)
|
|
83
|
+
const commandFiles = await safeReaddir(join(baseDir, "commands"));
|
|
84
|
+
for (const file of commandFiles.filter((f) => f.endsWith(".md"))) {
|
|
85
|
+
const name = file.replace(/\.md$/, "");
|
|
86
|
+
const origin = (await isBuiltIn("commands", file)) ? "built-in" : "user-created";
|
|
87
|
+
const entry: AssetEntry = { name, type: "command", origin };
|
|
88
|
+
|
|
89
|
+
if (shouldLint) {
|
|
90
|
+
try {
|
|
91
|
+
const content = await readFile(join(baseDir, "commands", file), "utf-8");
|
|
92
|
+
const lint = lintCommand(content);
|
|
93
|
+
commands.push({ ...entry, lint });
|
|
94
|
+
} catch {
|
|
95
|
+
commands.push({
|
|
96
|
+
...entry,
|
|
97
|
+
lint: { valid: false, errors: ["Could not read command file"], warnings: [] },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
commands.push(entry);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Scan agents (.md files)
|
|
106
|
+
const agentFiles = await safeReaddir(join(baseDir, "agents"));
|
|
107
|
+
for (const file of agentFiles.filter((f) => f.endsWith(".md"))) {
|
|
108
|
+
const name = file.replace(/\.md$/, "");
|
|
109
|
+
const origin = (await isBuiltIn("agents", file)) ? "built-in" : "user-created";
|
|
110
|
+
const entry: AssetEntry = { name, type: "agent", origin };
|
|
111
|
+
|
|
112
|
+
if (shouldLint) {
|
|
113
|
+
try {
|
|
114
|
+
const content = await readFile(join(baseDir, "agents", file), "utf-8");
|
|
115
|
+
const lint = lintAgent(content);
|
|
116
|
+
agents.push({ ...entry, lint });
|
|
117
|
+
} catch {
|
|
118
|
+
agents.push({
|
|
119
|
+
...entry,
|
|
120
|
+
lint: { valid: false, errors: ["Could not read agent file"], warnings: [] },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
agents.push(entry);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Compute summary
|
|
129
|
+
const allAssets = [...skills, ...commands, ...agents];
|
|
130
|
+
const builtIn = allAssets.filter((a) => a.origin === "built-in").length;
|
|
131
|
+
const userCreated = allAssets.filter((a) => a.origin === "user-created").length;
|
|
132
|
+
const lintErrors = shouldLint
|
|
133
|
+
? allAssets.reduce((sum, a) => sum + (a.lint?.errors.length ?? 0), 0)
|
|
134
|
+
: 0;
|
|
135
|
+
const lintWarnings = shouldLint
|
|
136
|
+
? allAssets.reduce((sum, a) => sum + (a.lint?.warnings.length ?? 0), 0)
|
|
137
|
+
: 0;
|
|
138
|
+
|
|
139
|
+
return JSON.stringify(
|
|
140
|
+
{
|
|
141
|
+
skills,
|
|
142
|
+
commands,
|
|
143
|
+
agents,
|
|
144
|
+
summary: {
|
|
145
|
+
total: allAssets.length,
|
|
146
|
+
builtIn,
|
|
147
|
+
userCreated,
|
|
148
|
+
lintErrors,
|
|
149
|
+
lintWarnings,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
null,
|
|
153
|
+
2,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const ocStocktake = tool({
|
|
158
|
+
description:
|
|
159
|
+
"Audit all installed skills, commands, and agents with optional YAML frontmatter lint validation.",
|
|
160
|
+
args: {
|
|
161
|
+
lint: tool.schema
|
|
162
|
+
.boolean()
|
|
163
|
+
.optional()
|
|
164
|
+
.default(true)
|
|
165
|
+
.describe("Run YAML frontmatter linter on all assets"),
|
|
166
|
+
},
|
|
167
|
+
async execute(args) {
|
|
168
|
+
return stocktakeCore(args, getGlobalConfigDir());
|
|
169
|
+
},
|
|
170
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { tool } from "@opencode-ai/plugin";
|
|
5
|
+
|
|
6
|
+
const execFile = promisify(execFileCb);
|
|
7
|
+
|
|
8
|
+
interface UpdateDocsArgs {
|
|
9
|
+
readonly scope?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface AffectedDoc {
|
|
13
|
+
readonly doc: string;
|
|
14
|
+
readonly reason: string;
|
|
15
|
+
readonly suggestion: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Run a git command and return stdout lines (empty array on error). */
|
|
19
|
+
async function gitLines(args: readonly string[], cwd: string): Promise<readonly string[]> {
|
|
20
|
+
try {
|
|
21
|
+
const { stdout } = await execFile("git", [...args], { cwd });
|
|
22
|
+
return stdout
|
|
23
|
+
.trim()
|
|
24
|
+
.split("\n")
|
|
25
|
+
.filter((line) => line.length > 0);
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function updateDocsCore(args: UpdateDocsArgs, projectDir: string): Promise<string> {
|
|
32
|
+
const scope = args.scope ?? "changed";
|
|
33
|
+
|
|
34
|
+
// Get changed source files
|
|
35
|
+
const changedFiles: readonly string[] =
|
|
36
|
+
scope === "all"
|
|
37
|
+
? await gitLines(["ls-files"], projectDir)
|
|
38
|
+
: await gitLines(["diff", "--name-only", "HEAD"], projectDir);
|
|
39
|
+
|
|
40
|
+
// Get all markdown files in the project
|
|
41
|
+
const mdFiles = await gitLines(["ls-files", "*.md"], projectDir);
|
|
42
|
+
|
|
43
|
+
// For each changed source file, check if any markdown file references it
|
|
44
|
+
const affectedDocs: AffectedDoc[] = [];
|
|
45
|
+
const seenDocs = new Set<string>();
|
|
46
|
+
|
|
47
|
+
for (const changedFile of changedFiles) {
|
|
48
|
+
// Skip markdown files themselves
|
|
49
|
+
if (changedFile.endsWith(".md")) continue;
|
|
50
|
+
|
|
51
|
+
const fileBaseName = basename(changedFile);
|
|
52
|
+
// Strip extension for module-style references
|
|
53
|
+
const moduleName = fileBaseName.replace(/\.[^.]+$/, "");
|
|
54
|
+
|
|
55
|
+
for (const mdFile of mdFiles) {
|
|
56
|
+
if (seenDocs.has(`${mdFile}:${changedFile}`)) continue;
|
|
57
|
+
|
|
58
|
+
// Simple heuristic: check if the markdown file path suggests it documents this area
|
|
59
|
+
// or if the changed file's name/module appears in common documentation patterns
|
|
60
|
+
const mdBaseName = basename(mdFile).replace(/\.md$/, "").toLowerCase();
|
|
61
|
+
const changedDir = changedFile.split("/").slice(0, -1).join("/");
|
|
62
|
+
|
|
63
|
+
const isRelated =
|
|
64
|
+
mdBaseName === "readme" ||
|
|
65
|
+
mdBaseName === moduleName.toLowerCase() ||
|
|
66
|
+
(changedDir.length > 0 && mdFile.toLowerCase().includes(changedDir.toLowerCase()));
|
|
67
|
+
|
|
68
|
+
if (isRelated) {
|
|
69
|
+
seenDocs.add(`${mdFile}:${changedFile}`);
|
|
70
|
+
affectedDocs.push({
|
|
71
|
+
doc: mdFile,
|
|
72
|
+
reason: `may be related to ${changedFile} (heuristic: path/name match)`,
|
|
73
|
+
suggestion: `Review ${mdFile} — it may need updates to reflect changes to ${changedFile}`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Deduplicate by doc path
|
|
80
|
+
const uniqueDocs = Array.from(
|
|
81
|
+
affectedDocs
|
|
82
|
+
.reduce((map, item) => {
|
|
83
|
+
if (!map.has(item.doc)) {
|
|
84
|
+
map.set(item.doc, item);
|
|
85
|
+
}
|
|
86
|
+
return map;
|
|
87
|
+
}, new Map<string, AffectedDoc>())
|
|
88
|
+
.values(),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const nonMdChanged = changedFiles.filter((f) => !f.endsWith(".md"));
|
|
92
|
+
|
|
93
|
+
return JSON.stringify(
|
|
94
|
+
{
|
|
95
|
+
changedFiles: nonMdChanged,
|
|
96
|
+
affectedDocs: uniqueDocs,
|
|
97
|
+
summary: `${nonMdChanged.length} source files changed, ${uniqueDocs.length} docs may need updates`,
|
|
98
|
+
},
|
|
99
|
+
null,
|
|
100
|
+
2,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const ocUpdateDocs = tool({
|
|
105
|
+
description: "Detect documentation affected by recent code changes and suggest updates.",
|
|
106
|
+
args: {
|
|
107
|
+
scope: tool.schema
|
|
108
|
+
.enum(["changed", "all"])
|
|
109
|
+
.optional()
|
|
110
|
+
.default("changed")
|
|
111
|
+
.describe("Scope: 'changed' for git diff, 'all' for full scan"),
|
|
112
|
+
},
|
|
113
|
+
async execute(args) {
|
|
114
|
+
return updateDocsCore(args, process.cwd());
|
|
115
|
+
},
|
|
116
|
+
});
|