akm-opencode 0.8.2 → 0.9.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.
@@ -0,0 +1,112 @@
1
+ // Minimal vendored semver implementation. Covers exactly the subset used by
2
+ // akm-hook.ts (`valid`, `satisfies` with caret ranges + `||` disjunction).
3
+ //
4
+ // Why vendored: the Claude plugin ships via `/plugin marketplace add` which
5
+ // just clones the repo — `node_modules` is gitignored and no install runs on
6
+ // the user's machine. Relying on Bun's auto-install for `semver` makes every
7
+ // hook silently no-op when auto-install is disabled, offline, or rate-limited.
8
+ // Vendoring this ~80 LOC removes the runtime dependency entirely.
9
+
10
+ type Parsed = {
11
+ major: number
12
+ minor: number
13
+ patch: number
14
+ prerelease: Array<string | number>
15
+ }
16
+
17
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
18
+
19
+ function parse(input: string): Parsed | null {
20
+ const m = String(input).trim().replace(/^v/, "").match(SEMVER_RE)
21
+ if (!m) return null
22
+ const [, maj, min, pat, pre] = m
23
+ const prerelease = pre ? pre.split(".").map((p) => (/^\d+$/.test(p) ? Number(p) : p)) : []
24
+ return { major: Number(maj), minor: Number(min), patch: Number(pat), prerelease }
25
+ }
26
+
27
+ export function valid(input: string | null | undefined): string | null {
28
+ if (!input) return null
29
+ if (!parse(input)) return null
30
+ return String(input).trim().replace(/^v/, "")
31
+ }
32
+
33
+ function cmp(a: Parsed, b: Parsed): number {
34
+ if (a.major !== b.major) return a.major < b.major ? -1 : 1
35
+ if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
36
+ if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
37
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
38
+ // A version without prerelease has HIGHER precedence than one with.
39
+ if (a.prerelease.length === 0) return 1
40
+ if (b.prerelease.length === 0) return -1
41
+ const n = Math.min(a.prerelease.length, b.prerelease.length)
42
+ for (let i = 0; i < n; i++) {
43
+ const ai = a.prerelease[i]
44
+ const bi = b.prerelease[i]
45
+ const aNum = typeof ai === "number"
46
+ const bNum = typeof bi === "number"
47
+ if (aNum && !bNum) return -1
48
+ if (!aNum && bNum) return 1
49
+ if (aNum && bNum) {
50
+ if (ai !== bi) return (ai as number) < (bi as number) ? -1 : 1
51
+ } else {
52
+ const s = String(ai)
53
+ const t = String(bi)
54
+ if (s !== t) return s < t ? -1 : 1
55
+ }
56
+ }
57
+ if (a.prerelease.length !== b.prerelease.length) return a.prerelease.length < b.prerelease.length ? -1 : 1
58
+ return 0
59
+ }
60
+
61
+ type CaretRange = { min: Parsed; maxExclusive: Parsed; allowPrereleases: boolean }
62
+
63
+ function parseCaretRange(token: string): CaretRange | null {
64
+ const m = token.trim().match(/^\^(.+)$/)
65
+ if (!m) return null
66
+ const min = parse(m[1])
67
+ if (!min) return null
68
+ let maxExclusive: Parsed
69
+ if (min.major > 0) {
70
+ maxExclusive = { major: min.major + 1, minor: 0, patch: 0, prerelease: [] }
71
+ } else if (min.minor > 0) {
72
+ maxExclusive = { major: 0, minor: min.minor + 1, patch: 0, prerelease: [] }
73
+ } else {
74
+ maxExclusive = { major: 0, minor: 0, patch: min.patch + 1, prerelease: [] }
75
+ }
76
+ return { min, maxExclusive, allowPrereleases: min.prerelease.length > 0 }
77
+ }
78
+
79
+ export function satisfies(version: string, range: string): boolean {
80
+ const v = parse(version)
81
+ if (!v) return false
82
+ const branches = range.split("||").map((s) => s.trim())
83
+ for (const branch of branches) {
84
+ const tokens = branch.split(/\s+/).filter(Boolean)
85
+ let ok = true
86
+ for (const t of tokens) {
87
+ const r = parseCaretRange(t)
88
+ if (!r) {
89
+ ok = false
90
+ break
91
+ }
92
+ if (cmp(v, r.min) < 0 || cmp(v, r.maxExclusive) >= 0) {
93
+ ok = false
94
+ break
95
+ }
96
+ // node-semver default: prereleases only satisfy a range whose lower
97
+ // bound also has a prerelease AND shares the same major.minor.patch.
98
+ if (v.prerelease.length > 0) {
99
+ if (!r.allowPrereleases) {
100
+ ok = false
101
+ break
102
+ }
103
+ if (v.major !== r.min.major || v.minor !== r.min.minor || v.patch !== r.min.patch) {
104
+ ok = false
105
+ break
106
+ }
107
+ }
108
+ }
109
+ if (ok) return true
110
+ }
111
+ return false
112
+ }
@@ -1,66 +0,0 @@
1
- ---
2
- mode: subagent
3
- description: AKM stash curator. Reviews session activity and proposes stash improvements.
4
- permission:
5
- task: deny
6
- tools:
7
- akm_env: deny
8
- write: deny
9
- bash: deny
10
- ---
11
-
12
- You are the AKM curator — a compound-engineering agent that keeps the user's AKM stash improving every time the main agent finishes a task.
13
-
14
- Inputs you should inspect:
15
- 1. OpenCode app logs that include the "akm-opencode" service (feedback, memory, tool invocations).
16
- 2. Session-summary memories named memory:opencode-session-*.
17
- 3. The live stash: call akm_search "" --limit 50 and akm_show <ref> to enumerate assets; use akm_help topic="list sources" when you need the configured-sources view.
18
- 4. Parent-session context via akm_parent_messages when this session was dispatched as a child.
19
-
20
- Signals to act on:
21
- - Hot refs: assets repeatedly appearing in positive tool outcomes. Call akm_feedback <ref> positive --note "curator: consistently useful" to reinforce.
22
- - Cold refs: assets tied to failures or user complaints. Record akm_feedback <ref> negative --note "<excerpt>" and open the asset for review.
23
- - Lesson candidates: repeated memories or failures that should become a proposed lesson. Use akm_help topic="improve" to surface the v0.8.0 improve flow (which replaces the old reflect/distill split) and propose an akm_improve <ref> call.
24
- - Missing coverage: recurring user prompts with no matching asset. Draft a new skill, command, knowledge doc, wiki page, or workflow in the working stash and reindex via the akm CLI (see akm_help topic="reindex").
25
- - Pending proposals: list or diff them via akm_help topic="proposal" and recommend accept, reject, or revise. Never accept or reject without explicit user approval.
26
- - Duplicates / drift: near-identical descriptions or overlapping responsibilities. Propose a consolidation.
27
- - Stale memories: session summaries that never get recalled. Propose removal (see akm_help topic="remove") once distilled into a durable knowledge doc or wiki page.
28
- - Wiki hygiene: for each wiki returned by akm_wiki list, run akm_wiki lint <name> and report orphans, broken xrefs, uncited raws, and stale indexes as fix candidates.
29
- - Stuck workflows: run akm_workflow list --active and surface any runs in blocked or failed state with their step ids. Propose whether to resume or escalate.
30
- - Never touch env or secret values: do not call akm_env run or akm_secret path unless the user explicitly asks. Values must never appear in reports.
31
-
32
- Rules of engagement:
33
- - Never apply destructive changes without explicit user approval.
34
- - Report findings as a prioritized action list of concrete akm_* tool calls the user can run.
35
- - Prefer small, reversible edits: promote via positive feedback, draft a candidate skill, or clone and tweak.
36
- - When drafting new assets, write them into the working stash directory under skills/, commands/, agents/, knowledge/, or scripts/. Use akm_help (topic="config" / topic="reindex") to look up the right CLI invocation when you need the stash path or want to force a reindex.
37
- - When finished, persist your own summary with akm_remember (name: curator-run-<timestamp>) so the next curator run can build on yours.
38
-
39
- Output shape: end every run with a markdown report that has these sections:
40
-
41
- ## Hot assets (promote)
42
- - <ref> — why it helped — command to run
43
-
44
- ## Cold assets (investigate)
45
- - <ref> — failure signal — proposed fix
46
-
47
- ## Lesson candidates
48
- - <theme> — evidence refs — `akm improve <ref>` or `akm propose <type> <name> --task "..."` command to run
49
-
50
- ## Coverage gaps
51
- - <theme> — proposed asset (type, name, one-line description)
52
-
53
- ## Pending proposals
54
- - <proposal id> — summary — accept/reject/revise recommendation
55
-
56
- ## Duplicates / drift
57
- - <ref a> vs <ref b> — consolidation proposal
58
-
59
- ## Wiki health
60
- - <wiki> — lint findings (orphan, broken-xref, uncited-raw, stale-index) with suggested fix
61
-
62
- ## Workflow health
63
- - <workflow|runId> — blocked/failed state — resume or escalate
64
-
65
- ## Housekeeping
66
- - stale memories, reindex needs, config tweaks
@@ -1,15 +0,0 @@
1
- Run a full AKM session evolution review.
2
-
3
- End with these sections:
4
-
5
- ## Hot assets
6
- ## Cold assets
7
- ## Lesson candidates
8
- ## Coverage gaps
9
- ## Pending proposals
10
- ## Duplicates / drift
11
- ## Wiki health
12
- ## Workflow health
13
- ## Housekeeping
14
-
15
- Use `akm_help` before long-tail raw CLI commands, and never accept or reject proposals or run risky AKM commands without explicit user approval.
@@ -1,9 +0,0 @@
1
- Improve existing AKM assets or distill repeated evidence into proposals.
2
-
3
- 1. Identify the strongest evidence refs or the asset type that needs work.
4
- 2. Record negative feedback when justified.
5
- 3. Call `akm_help` with `topic: "improve"`.
6
- 4. Run `akm improve [<type>|<ref>] [--task "..."]`.
7
- 5. List resulting pending proposals and do not accept or reject them without explicit user approval.
8
-
9
- Improve-profile config (`profiles.improve.<name>`) shapes the run: `processes.triage` is a triage PRE-pass that drains the pending backlog by a deterministic policy (`{ enabled, applyMode: queue|promote, policy, maxAcceptsPerRun, maxDiffLines, rejectEmpty, judgment }`, same engine as `akm proposal drain`); `sync` (`{ enabled, push, message }`, with `{timestamp}{date}{time}{scope}{refs}{accepted}` message tokens) commits/pushes a git-backed stash at end of run. Override sync with `akm improve --sync/--no-sync` and `--push/--no-push`. Triage + `akm proposal drain` is the built-in replacement for the old manual proposal-management agent session.
@@ -1,8 +0,0 @@
1
- Create a proposed AKM asset for a coverage gap.
2
-
3
- 1. Search or curate first.
4
- 2. Confirm the gap is real.
5
- 3. Call `akm_help` with `topic: "propose"`.
6
- 4. Choose the smallest suitable asset type.
7
- 5. Run `akm propose <type> <name> --task "..."` or `akm propose <type> <name> --file ./prompt.md`.
8
- 6. Show proposal review commands and remind the user that proposed assets are not curated until accepted.
@@ -1,8 +0,0 @@
1
- Review pending AKM proposals safely.
2
-
3
- 1. Call `akm_help` with `topic: "proposal"`.
4
- 2. Run `akm proposal list --status pending --format json`.
5
- 3. For relevant proposals, run `akm proposal show <id>` and `akm proposal diff <id>` (positional id).
6
- 4. Summarize the likely accept, reject, or revise outcome.
7
- 5. Do not run `akm proposal accept` or `akm proposal reject` unless the user explicitly approves the exact command.
8
- 6. For a large backlog, suggest the deterministic bulk path: `akm proposal drain --policy <personal-stash|conservative|manual> --dry-run` to preview, then `--promote --yes` only after explicit approval (mutating; commits to git, no batch revert). This — plus the automatic `processes.triage` pre-pass inside `akm improve` — is the built-in replacement for the old manual proposal-management agent session.
@@ -1,7 +0,0 @@
1
- Inspect the current AKM workflow state safely.
2
-
3
- 1. List active workflow runs.
4
- 2. Show blocked or failed steps.
5
- 3. Identify the next evidence needed.
6
- 4. Recommend only safe next actions.
7
- 5. Do not bypass review, approval, or verification requirements.
@@ -1,196 +0,0 @@
1
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
- import path from "node:path"
3
- import { redactObject } from "./redaction"
4
-
5
- // Memory candidates can contain prompt fragments, ref names, and (despite
6
- // redaction) potentially sensitive contextual data harvested from session
7
- // activity. Lock the on-disk file down to user-only read/write so multi-user
8
- // hosts (CI runners, shared VMs, dev sandboxes) cannot side-read another
9
- // user's stash signals.
10
- function chmodSafe(filePath: string, mode: number): void {
11
- try {
12
- chmodSync(filePath, mode)
13
- } catch {
14
- // Best-effort: filesystems without POSIX mode (FAT, some FUSE mounts) or
15
- // platforms where chmod is a no-op (Windows) silently skip. Don't crash
16
- // the hook over a hardening attempt.
17
- }
18
- }
19
-
20
- export type AkmMemoryCandidate = {
21
- id: string
22
- createdAt: string
23
- harness: "claude-code" | "opencode"
24
- sessionId?: string
25
- sourceEventIds?: string[]
26
- sourcePaths?: string[]
27
- type:
28
- | "preference"
29
- | "constraint"
30
- | "decision"
31
- | "lesson"
32
- | "workflow_state"
33
- | "asset_feedback"
34
- | "coverage_gap"
35
- | "stale_memory"
36
- | "unknown"
37
- scope: "user" | "project" | "repo" | "branch" | "session" | "agent" | "workflow"
38
- content: string
39
- evidence: string[]
40
- confidence: number
41
- recommendedAction: "remember" | "distill" | "propose" | "feedback" | "ignore"
42
- targetRef?: string
43
- status: "pending" | "promoted" | "rejected"
44
- reason?: string
45
- }
46
-
47
- const AKM_REF_RE = /(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|env|secret|wiki|lesson):[A-Za-z0-9._/-]+/g
48
-
49
- function uniq(values: string[]): string[] {
50
- return [...new Set(values)]
51
- }
52
-
53
- function pickTargetRef(refs: string[]): string | undefined {
54
- return refs.find((ref) => !ref.startsWith("memory:") && !ref.startsWith("env:") && !ref.startsWith("secret:")) ?? refs[0]
55
- }
56
-
57
- function extractRefs(value: string): string[] {
58
- return uniq(value.match(AKM_REF_RE) ?? [])
59
- }
60
-
61
- export function getCandidateLogPath(harness: "claude-code" | "opencode"): string {
62
- const root = process.env.XDG_STATE_HOME ?? path.join(process.env.HOME ?? ".", ".local", "state")
63
- const dir = path.join(root, harness === "claude-code" ? "akm-claude" : "akm-opencode")
64
- return path.join(dir, "memory-candidates.jsonl")
65
- }
66
-
67
- function makeCandidateId(harness: string, sessionId: string | undefined, index: number): string {
68
- const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)
69
- const sid = (sessionId ?? "session").replace(/[^A-Za-z0-9._-]/g, "").slice(0, 12) || "session"
70
- return `${harness}-${sid}-${stamp}-${index}`
71
- }
72
-
73
- function classifyCandidate(
74
- content: string,
75
- refs: string[],
76
- ): Pick<AkmMemoryCandidate, "type" | "scope" | "confidence" | "recommendedAction" | "targetRef"> {
77
- const text = content.toLowerCase()
78
- const targetRef = pickTargetRef(refs)
79
- if (targetRef && /\b(worked|helped|useful|failed|broken|wrong|didn't work|did not work)\b/.test(text)) {
80
- return { type: "asset_feedback", scope: "project", confidence: 0.72, recommendedAction: "feedback", targetRef }
81
- }
82
- if (/\b(always|never|must|should not|cannot|can't|do not|required)\b/.test(text)) {
83
- return { type: "constraint", scope: "project", confidence: 0.8, recommendedAction: "remember" }
84
- }
85
- if (/\b(prefer|likes|dislikes|wants|remember)\b/.test(text)) {
86
- return { type: "preference", scope: "user", confidence: 0.8, recommendedAction: "remember" }
87
- }
88
- if (/\b(decided|decision|use |chosen|architecture|approach)\b/.test(text)) {
89
- return { type: "decision", scope: "project", confidence: 0.75, recommendedAction: "remember" }
90
- }
91
- if (/\b(blocked|failed|workflow|next step|resume)\b/.test(text)) {
92
- return { type: "workflow_state", scope: "workflow", confidence: 0.7, recommendedAction: "remember" }
93
- }
94
- if (/\b(missing|coverage gap|todo|follow-up|followup)\b/.test(text)) {
95
- return { type: "coverage_gap", scope: "project", confidence: 0.65, recommendedAction: "propose" }
96
- }
97
- if (/\b(lesson|fix|worked|resolved|solution)\b/.test(text)) {
98
- return { type: "lesson", scope: "project", confidence: 0.7, recommendedAction: "distill", targetRef }
99
- }
100
- return { type: "unknown", scope: "session", confidence: 0.4, recommendedAction: "ignore" }
101
- }
102
-
103
- export function extractCandidatesFromText(input: {
104
- harness: "claude-code" | "opencode"
105
- sessionId?: string
106
- text: string
107
- evidence?: string[]
108
- sourceEventIds?: string[]
109
- sourcePaths?: string[]
110
- targetRefHints?: string[]
111
- }): AkmMemoryCandidate[] {
112
- const lines = input.text
113
- .split(/\r?\n/)
114
- .map((line) => line.trim())
115
- .filter((line) => line.length >= 20)
116
- .filter((line) => !line.startsWith("#") && !line.startsWith("## "))
117
-
118
- const selected = lines.filter((line) => /\b(always|never|must|should|prefer|remember|decision|decided|workflow|blocked|failed|worked|fix|missing|follow-up|followup)\b/i.test(line))
119
- return selected.slice(0, 12).map((content, index) => {
120
- const contentRefs = extractRefs(content)
121
- const evidenceRefs = (input.evidence ?? []).flatMap(extractRefs)
122
- const hintedRefs = input.targetRefHints ?? []
123
- const refs = uniq([
124
- ...contentRefs,
125
- ...hintedRefs,
126
- ...evidenceRefs,
127
- ])
128
- const details = classifyCandidate(content, refs)
129
- const targetRef = details.targetRef ?? pickTargetRef([...contentRefs, ...hintedRefs, ...evidenceRefs])
130
- return {
131
- id: makeCandidateId(input.harness, input.sessionId, index + 1),
132
- createdAt: new Date().toISOString(),
133
- harness: input.harness,
134
- sessionId: input.sessionId,
135
- sourceEventIds: input.sourceEventIds,
136
- sourcePaths: input.sourcePaths ? uniq(input.sourcePaths) : undefined,
137
- type: details.type,
138
- scope: details.scope,
139
- content,
140
- evidence: uniq([...(input.evidence ?? []), content]),
141
- confidence: details.confidence,
142
- recommendedAction: details.recommendedAction,
143
- targetRef,
144
- status: "pending",
145
- }
146
- })
147
- }
148
-
149
- export function appendCandidates(filePath: string, candidates: AkmMemoryCandidate[]): { ok: true; count: number; categories: string[] } | { ok: false; error: string } {
150
- try {
151
- mkdirSync(path.dirname(filePath), { recursive: true })
152
- chmodSafe(path.dirname(filePath), 0o700)
153
- const categories: string[] = []
154
- const created = !existsSync(filePath)
155
- for (const candidate of candidates) {
156
- const redacted = redactObject(candidate)
157
- categories.push(...redacted.categories)
158
- appendFileSync(filePath, `${JSON.stringify(redacted.value)}\n`)
159
- }
160
- if (created) chmodSafe(filePath, 0o600)
161
- return { ok: true, count: candidates.length, categories: [...new Set(categories)] }
162
- } catch (error: unknown) {
163
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
164
- }
165
- }
166
-
167
- export function readCandidates(filePath: string): AkmMemoryCandidate[] {
168
- if (!existsSync(filePath)) return []
169
- return readFileSync(filePath, "utf8")
170
- .split("\n")
171
- .filter(Boolean)
172
- .flatMap((line) => {
173
- try {
174
- return [JSON.parse(line) as AkmMemoryCandidate]
175
- } catch {
176
- return []
177
- }
178
- })
179
- }
180
-
181
- export function replaceCandidates(filePath: string, candidates: AkmMemoryCandidate[]): void {
182
- mkdirSync(path.dirname(filePath), { recursive: true })
183
- chmodSafe(path.dirname(filePath), 0o700)
184
- writeFileSync(filePath, candidates.map((candidate) => `${JSON.stringify(candidate)}\n`).join(""))
185
- chmodSafe(filePath, 0o600)
186
- }
187
-
188
- export function updateCandidateStatus(filePath: string, id: string, status: "promoted" | "rejected", reason?: string): AkmMemoryCandidate | undefined {
189
- const candidates = readCandidates(filePath)
190
- const index = candidates.findIndex((candidate) => candidate.id === id)
191
- if (index === -1) return undefined
192
- const updated = { ...candidates[index], status, reason }
193
- candidates[index] = updated
194
- replaceCandidates(filePath, candidates)
195
- return updated
196
- }