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.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
- "description": "OpenCode plugin for AKM v0.8.0 - search, show, and manage extension assets via the akm CLI, including vaults, wikis, workflows, the proposal queue, lesson assets, and improve/propose commands, with agentic hooks that auto-load relevant stash assets, record feedback, and harvest session memories so the stash improves every session.",
5
+ "description": "OpenCode plugin for AKM 0.9 with in-process search, show, and curate tools plus feedback and remember.",
6
6
  "keywords": [
7
7
  "opencode",
8
8
  "opencode-ai",
@@ -25,8 +25,6 @@
25
25
  "license": "MPL-2.0",
26
26
  "files": [
27
27
  "index.ts",
28
- "agent/",
29
- "commands/",
30
28
  "shared/",
31
29
  "README.md"
32
30
  ],
@@ -36,11 +34,15 @@
36
34
  },
37
35
  "scripts": {
38
36
  "prepack": "node ./scripts/vendor-shared.mjs",
39
- "postpack": "node ./scripts/unvendor-shared.mjs"
37
+ "postpack": "node ./scripts/unvendor-shared.mjs",
38
+ "typecheck": "tsc -p tsconfig.json"
40
39
  },
41
40
  "dependencies": {
42
- "@opencode-ai/plugin": "^1.2.20",
43
- "akm-cli": "^0.8.0",
44
- "semver": "^7.7.2"
41
+ "@opencode-ai/plugin": "^1.18.14",
42
+ "akm-cli": "^0.9.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^26.1.1",
46
+ "typescript": "^5.8.3"
45
47
  }
46
48
  }
@@ -0,0 +1,45 @@
1
+ // Single source of truth for the akm-cli version contract.
2
+ //
3
+ // Both the Claude hook (claude/hooks/akm-hook.ts) and the OpenCode plugin
4
+ // (opencode/index.ts) validate the user's installed akm-cli against this exact
5
+ // range. Keeping it here — alongside the vendored semver matcher — means a
6
+ // version bump is a one-line change in one file instead of three drifting
7
+ // copies (a hand-rolled `minor === 8` check previously diverged here).
8
+ //
9
+ // The matcher is the vendored `satisfies()` rather than the npm `semver`
10
+ // package because the Claude hook runs as a bare Bun script with no
11
+ // node_modules at hook-execution time. This module is vendored into the
12
+ // published OpenCode tarball by opencode/scripts/vendor-shared.mjs, so the
13
+ // same code path runs on both sides.
14
+ //
15
+ // A single caret clause anchored at the stable release covers the whole
16
+ // supported line: `^0.9.0` admits every stable 0.9.x (0.9.0, 0.9.3, ...).
17
+ // The pre-release floor (`^0.9.0-rc.14`) was retired when akm-cli 0.9.0
18
+ // stable shipped: release candidates are dead once the release exists, and
19
+ // keeping the RC floor would have kept recommending a prerelease install ref.
20
+ //
21
+ // KNOWN GAP: NO prerelease satisfies this range — not 0.9.0-rc.15, and not a
22
+ // *future* line such as 0.9.1-rc.1. That is node-semver's documented behavior
23
+ // and the vendored matcher reproduces it: a prerelease only satisfies a range
24
+ // whose lower bound is a prerelease with the same major.minor.patch. Admitting
25
+ // a prerelease line again is an explicit one-clause edit here
26
+ // (`^0.9.0 || ^0.9.1-rc.1`) when such a build actually needs testing — a
27
+ // deliberate opt-in rather than a range that silently accepts untested
28
+ // prereleases.
29
+ //
30
+ // 0.8.0 support was dropped for the 0.9.0 release: 0.9.0-only runtime paths
31
+ // (`akm proposal extract --session-id`, curate `--detail brief`) fail against
32
+ // 0.8.x, so accepting 0.8.x here would silently pass the version gate onto a
33
+ // CLI the plugin no longer fully works with.
34
+
35
+ import { satisfies } from "./vendor-semver"
36
+
37
+ export const AKM_VERSION_RANGE = "^0.9.0"
38
+
39
+ /**
40
+ * True when `version` is a valid semver string that satisfies
41
+ * {@link AKM_VERSION_RANGE}. Non-strings (e.g. a failed probe) return false.
42
+ */
43
+ export function satisfiesAkmVersionRange(version: string | null | undefined): boolean {
44
+ return typeof version === "string" && satisfies(version, AKM_VERSION_RANGE)
45
+ }
@@ -17,6 +17,45 @@ export type AkmFeedbackSignal = {
17
17
  harness: "claude-code" | "opencode"
18
18
  }
19
19
 
20
+ /**
21
+ * Retrospective feedback matchers. Both plugins classify a user message as
22
+ * after-the-fact praise or complaint about assets the session already touched,
23
+ * so the grammar lives here rather than in one harness: the two sides must
24
+ * agree on what counts as a signal, or the same message produces opposite
25
+ * verdicts depending on the harness.
26
+ *
27
+ * AKM_RETROSPECTIVE_FEEDBACK_PATTERN / AKM_RETROSPECTIVE_NEGATIVE_PATTERN let
28
+ * an operator retune the vocabulary (other languages, project jargon). A
29
+ * user-supplied pattern is untrusted input, so an invalid one falls back to the
30
+ * default instead of throwing at module load and taking the plugin down with
31
+ * it.
32
+ */
33
+ export function createRetrospectiveFeedbackRegex(): RegExp {
34
+ const pattern = process.env.AKM_RETROSPECTIVE_FEEDBACK_PATTERN ?? "\\b(thanks|perfect|worked)\\b"
35
+ try {
36
+ return new RegExp(pattern, "i")
37
+ } catch {
38
+ return /\b(thanks|perfect|worked)\b/i
39
+ }
40
+ }
41
+
42
+ export function createRetrospectiveNegativeRegex(): RegExp {
43
+ const pattern = process.env.AKM_RETROSPECTIVE_NEGATIVE_PATTERN ?? "\\b(wrong|failed|broken|didn't work|did not work|bad)\\b"
44
+ try {
45
+ return new RegExp(pattern, "i")
46
+ } catch {
47
+ return /\b(wrong|failed|broken|didn't work|did not work|bad)\b/i
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Explicit corrections ("that was wrong") are a stronger, narrower signal than
53
+ * the tunable negative matcher, so this one is deliberately not overridable.
54
+ */
55
+ export function createExplicitCorrectionRegex(): RegExp {
56
+ return /\b(this was wrong|that was wrong|you were wrong|incorrect|not correct)\b/i
57
+ }
58
+
20
59
  export function getAutoFeedbackMinConfidence(): number {
21
60
  const raw = Number(process.env.AKM_AUTO_FEEDBACK_MIN_CONFIDENCE ?? "0.6")
22
61
  return Number.isFinite(raw) ? raw : 0.6
@@ -1,18 +1,18 @@
1
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs"
1
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs"
2
2
  import path from "node:path"
3
3
  import { redactObject } from "./redaction"
4
-
5
4
  // Memory events log session activity (refs touched, outcomes, scope). Even
6
- // post-redaction this is a privileged record; lock the directory + file down
7
- // to owner-only access. Mirrors memory-candidates' chmodSafe helper.
8
- function chmodSafe(target: string, mode: number): void {
9
- try {
10
- chmodSync(target, mode)
11
- } catch {
12
- // Best-effort; see memory-candidates.ts for rationale.
13
- }
14
- }
5
+ // post-redaction this is a privileged record, so the directory + file are
6
+ // locked to owner-only and events.jsonl is size-capped like every other
7
+ // append-only state file. Both primitives live in ./state-files so the Claude
8
+ // hook and this module share one implementation.
9
+ import { chmodSafe, rotateIfOversized } from "./state-files"
15
10
 
11
+ // Exactly the event names some shipped surface emits. The union used to carry
12
+ // 18 more (workflow_*, candidate_*, session_ended, ...) that no call site ever
13
+ // wrote — a vocabulary describing pipelines that were removed or never built.
14
+ // Keep this list emitter-driven: add a name when something emits it, not in
15
+ // anticipation.
16
16
  export type AkmMemoryEventType =
17
17
  | "session_started"
18
18
  | "prompt_recall"
@@ -20,29 +20,11 @@ export type AkmMemoryEventType =
20
20
  | "tool_batch_observation"
21
21
  | "tool_ref_observed"
22
22
  | "workflow_step"
23
- | "workflow_started"
24
- | "workflow_next_loaded"
25
- | "workflow_step_completed"
26
- | "workflow_step_blocked"
27
- | "workflow_step_failed"
28
- | "workflow_step_skipped"
29
- | "workflow_evidence_attached"
30
- | "workflow_drift_detected"
31
- | "workflow_resumed"
32
- | "workflow_abandoned"
33
23
  | "task_created"
34
24
  | "task_completed"
35
25
  | "subagent_started"
36
- | "subagent_completed"
37
- | "pre_compact_checkpoint"
38
26
  | "post_compact_summary"
39
- | "session_ended"
40
- | "candidate_extracted"
41
- | "candidate_promoted"
42
- | "candidate_rejected"
43
- | "durable_memory_written"
44
27
  | "feedback_recorded"
45
- | "safety_blocked"
46
28
 
47
29
  export type AkmMemoryEvent = {
48
30
  version: 1
@@ -90,6 +72,7 @@ export function appendMemoryEvent(filePath: string, event: AkmMemoryEvent): { ok
90
72
  try {
91
73
  mkdirSync(path.dirname(filePath), { recursive: true })
92
74
  chmodSafe(path.dirname(filePath), 0o700)
75
+ rotateIfOversized(filePath)
93
76
  const redacted = redactObject(event)
94
77
  const enriched = {
95
78
  ...redacted.value,
@@ -106,17 +89,3 @@ export function appendMemoryEvent(filePath: string, event: AkmMemoryEvent): { ok
106
89
  return { ok: false, error: error instanceof Error ? error.message : String(error) }
107
90
  }
108
91
  }
109
-
110
- export function readJsonl<T>(filePath: string): T[] {
111
- if (!existsSync(filePath)) return []
112
- return readFileSync(filePath, "utf8")
113
- .split("\n")
114
- .filter(Boolean)
115
- .flatMap((line) => {
116
- try {
117
- return [JSON.parse(line) as T]
118
- } catch {
119
- return []
120
- }
121
- })
122
- }
@@ -1,89 +1,30 @@
1
1
  /**
2
- * AKM ref extraction & live-stash validation.
2
+ * AKM 0.9 ref extraction and local bundle validation.
3
3
  *
4
- * Background: session-checkpoint memories captured by the hook embed Bash
5
- * command bodies verbatim — heredocs, grep patterns, jq queries, JSON
6
- * payloads. Naive ref extraction (`text.match(REF_PATTERN)`) treats every
7
- * `<type>:<slug>` token as a real reference, which causes `akm lint` to
8
- * flag string-literal tokens as `missing-ref`. The next session capture
9
- * regenerates the same flags — a permanent lint treadmill.
10
- *
11
- * The fix (this module): we drop the "guess from context" heuristic and
12
- * instead **validate every candidate against the live local stash**. A
13
- * token only graduates from "candidate" to "ref" when the referenced asset
14
- * actually exists on disk. Anything that doesn't resolve is silently
15
- * dropped — including all the literal-string false positives.
16
- *
17
- * The validation logic mirrors the consumer-side lint walker
18
- * (`src/commands/lint/base-linter.ts#refExistsInAnyStash`). We deliberately
19
- * inline a small copy here (rather than spawn a subprocess) so the hook's
20
- * post-tool path stays cheap (zero subprocess, zero JSON parse). The two
21
- * resolvers must stay in sync; any new asset type added to lint's
22
- * `refToRelPath` must be added here too.
4
+ * Session checkpoints can contain command bodies, heredocs, and serialized
5
+ * tool output. Extraction is deliberately permissive, then candidates are
6
+ * retained only when their concept ID resolves inside a provided bundle root.
7
+ * This keeps the hook subprocess-free and drops ref-shaped string literals.
23
8
  */
24
9
 
25
- // CONTRACT: ref-resolver
26
- // ----------------------------------------------------------------------------
27
- // The `refExistsInAnyStash` and `refToRelPath` helpers below are
28
- // contract-locked: a sister copy lives in the akm-core repo at
29
- // `src/commands/lint/base-linter.ts`. Both implementations resolve the same
30
- // `<type>:<slug>` -> on-disk-asset question and MUST agree on the set of
31
- // reachable refs for any given stash layout.
32
- //
33
- // The lock is enforced by `tests/ref-resolver-contract.test.ts`, which drives
34
- // `validateRefCandidates` (the only public entry to this resolver) through a
35
- // canonical fixture set. The akm-core repo ships an equivalent test at
36
- // `tests/contracts/ref-resolver-contract.test.ts` that drives ITS copy
37
- // through the SAME inputs. Any change to the resolver behavior on either
38
- // side MUST update both contract tests in lockstep, or one will fail.
39
- //
40
- // NOTE: this file is the SECOND copy of the resolver. The runtime-shipped
41
- // copy lives at `claude/shared/ref-extraction.ts` and is imported by the
42
- // post-tool hook. Both copies must agree with each other AND with the
43
- // akm-core resolver. The contract test runs against `../shared/ref-extraction`
44
- // (this file) — the divergence between this file and the runtime copy is
45
- // tracked separately (regex tightness only, see top-level repo notes).
46
- // ----------------------------------------------------------------------------
47
-
48
- import { existsSync, statSync, readdirSync } from "node:fs";
10
+ import { existsSync, statSync } from "node:fs";
49
11
  import path from "node:path";
50
12
 
51
- /**
52
- * Permissive ref regex. Matches `[origin//]type:slug` for the known asset
53
- * types. Origins (e.g. `local//`, `npm:foo//`) are tolerated but stripped
54
- * before validation — only `local//` refs are resolvable against the local
55
- * stash; anything else is dropped because we cannot validate it offline.
56
- *
57
- * Kept in sync with the lint walker pattern in
58
- * `src/commands/lint/base-linter.ts`.
59
- */
60
- // Slug body allows the same charset as the lint walker, but the closing
61
- // character must be alphanumeric, `_`, or `-` — so a trailing `.` or `/`
62
- // from natural prose (`see memory:rollout-notes.`) does not leak into
63
- // the captured ref string. Mirrors the consumer-side
64
- // `src/commands/lint/base-linter.ts` REF_RE, which terminates on a
65
- // punctuation set including `.` via lookahead. We allow `.` mid-slug
66
- // (e.g. `env:.env`-style names) by requiring the slug to be at least
67
- // one character and to *end* on `[A-Za-z0-9_-]`.
13
+ // Passive extraction is intentionally narrower than AKM's explicit ref parser:
14
+ // only standard concept roots are observed. This prevents ordinary paths such
15
+ // as src/app.ts from becoming automatic feedback targets.
16
+ //
17
+ // The concept-root list is the exact set of directories `akm bundle create`
18
+ // scaffolds in 0.9 (agents commands env facts instructions knowledge lessons
19
+ // memories scripts secrets sessions skills tasks workflows), which matches the
20
+ // `assetTypes` array reported by `akm info --format json` modulo pluralization.
21
+ // `wikis` was dropped for 0.9 — it is neither an asset type nor a scaffolded
22
+ // directory — and facts/instructions/sessions were added. Keep this list byte
23
+ // for byte in sync with opencode/index.ts and evals/tier2/metrics/feedback.ts.
68
24
  const REF_PATTERN =
69
- /(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|lesson|script|workflow|task|env|secret|wiki):(?:[A-Za-z0-9._/-]*[A-Za-z0-9_-]|[A-Za-z0-9_-])/g;
70
-
71
- /**
72
- * Return every `<type>:<slug>` token in `text` regardless of context.
73
- * Order matches first-occurrence; duplicates are removed.
74
- */
75
- export function extractAllRefs(text: string): string[] {
76
- if (!text) return [];
77
- return [...new Set(text.match(REF_PATTERN) ?? [])];
78
- }
79
-
80
- // Tokenized whitespace-split fallback used by callers that need a strict
81
- // "this token, in isolation, is a ref" answer (e.g. PreToolUse non-Bash
82
- // observation, which inspects a single tool input field rather than a
83
- // transcript-style body). Kept for backward compatibility with existing
84
- // callers in `claude/hooks/akm-hook.ts` and the opencode plugin.
25
+ /(?<![A-Za-z0-9@._+/:=-])(?:[A-Za-z0-9@._+-]+\/\/)?(?:agents|commands|env|facts|instructions|knowledge|lessons|memories|scripts|secrets|sessions|skills|tasks|workflows)\/[A-Za-z0-9._/-]+(?:#[A-Za-z0-9._~!$&'()*+,;=:@%/?-]+)?(?![A-Za-z0-9@._+/#$=-])/g;
85
26
  const AKM_REF_STRICT =
86
- /^(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|task|env|secret|wiki|lesson):[A-Za-z0-9._/\-]+$/;
27
+ /^(?:[A-Za-z0-9@._+-]+\/\/)?(?:agents|commands|env|facts|instructions|knowledge|lessons|memories|scripts|secrets|sessions|skills|tasks|workflows)\/[A-Za-z0-9._/-]+(?:#[A-Za-z0-9._~!$&'()*+,;=:@%/?-]+)?$/;
87
28
  const EDGE_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "'", "\"", "`"]);
88
29
 
89
30
  function normalizeToken(token: string): string {
@@ -94,6 +35,18 @@ function normalizeToken(token: string): string {
94
35
  return token.slice(start, end);
95
36
  }
96
37
 
38
+ /** Return all ref-shaped tokens in first-occurrence order, deduplicated. */
39
+ export function extractAllRefs(text: string): string[] {
40
+ if (!text) return [];
41
+ const refs = new Set<string>();
42
+ for (const match of text.match(REF_PATTERN) ?? []) {
43
+ const normalized = normalizeToken(match);
44
+ if (AKM_REF_STRICT.test(normalized)) refs.add(normalized);
45
+ }
46
+ return [...refs];
47
+ }
48
+
49
+ /** Return whitespace-delimited tokens that are complete AKM refs. */
97
50
  export function extractAkmRefsFromString(text: string): string[] {
98
51
  const refs = new Set<string>();
99
52
  for (const token of text.split(/\s+/)) {
@@ -103,159 +56,65 @@ export function extractAkmRefsFromString(text: string): string[] {
103
56
  return [...refs];
104
57
  }
105
58
 
106
- /**
107
- * Map ref type → relative path within a stash root.
108
- * Returns `null` for types that cannot be resolved by direct path
109
- * (scripts live in nested dirs; remote-only types).
110
- */
111
- function refToRelPath(refType: string, refName: string): string | null {
112
- switch (refType) {
113
- case "agent":
114
- return path.join("agents", `${refName}.md`);
115
- case "command":
116
- return path.join("commands", `${refName}.md`);
117
- case "knowledge":
118
- return path.join("knowledge", `${refName}.md`);
119
- case "memory":
120
- return path.join("memories", `${refName}.md`);
121
- case "script":
122
- return null; // scripts live in nested dirs — skip
123
- case "skill":
124
- return path.join("skills", refName, "SKILL.md");
125
- case "workflow":
126
- return path.join("workflows", `${refName}.md`);
127
- case "lesson":
128
- return path.join("lessons", `${refName}.md`);
129
- case "task":
130
- return path.join("tasks", `${refName}.md`);
131
- case "wiki":
132
- return path.join("wikis", `${refName}.md`);
133
- case "env":
134
- if (!refName || refName === "default") return path.join("env", ".env");
135
- return path.join("env", `${refName}.env`);
136
- case "secret":
137
- return path.join("secrets", refName);
138
- default:
139
- return null;
140
- }
59
+ interface NormalizedRef {
60
+ canonical: string;
61
+ conceptId: string;
141
62
  }
142
63
 
143
- /**
144
- * True if `<type>:<refName>` resolves to a real file under any provided
145
- * stash root. Mirrors `refExistsInAnyStash` in `src/commands/lint/base-linter.ts`.
146
- */
147
- function refExistsInAnyStash(refType: string, refName: string, stashRoots: readonly string[]): boolean {
148
- const relPath = refToRelPath(refType, refName);
149
- if (!relPath) return false;
150
- for (const root of stashRoots) {
151
- if (!root) continue;
152
- const absPath = path.join(root, relPath);
153
- if (existsSync(absPath)) return true;
154
- // Multi-file skill layout: directory containing SKILL.md
155
- const bareDir = absPath.replace(/\.md$/, "");
156
- try {
157
- if (existsSync(bareDir) && existsSync(path.join(bareDir, "SKILL.md"))) return true;
158
- } catch {
159
- // ignore
160
- }
161
- // .derived.md variant for memory refs
162
- if (refType === "memory") {
163
- const derivedPath = path.join(root, "memories", `${refName}.derived.md`);
164
- if (existsSync(derivedPath)) return true;
165
- }
166
- // Knowledge subdirectory layout (knowledge/projects/foo/...)
167
- if (refType === "knowledge") {
168
- try {
169
- const knowledgeDir = path.join(root, "knowledge");
170
- if (existsSync(knowledgeDir) && statSync(knowledgeDir).isDirectory()) {
171
- for (const entry of readdirSync(knowledgeDir)) {
172
- const subPath = path.join(knowledgeDir, entry, `${refName}.md`);
173
- if (existsSync(subPath)) return true;
174
- }
175
- }
176
- } catch {
177
- // ignore
178
- }
179
- }
180
- // Fallback: refName already encodes the stash-relative path
181
- const directPath = path.join(root, `${refName}.md`);
182
- if (existsSync(directPath)) return true;
183
- const directDir = path.join(root, refName);
184
- try {
185
- if (existsSync(directDir) && existsSync(path.join(directDir, "SKILL.md"))) return true;
186
- } catch {
187
- // ignore
188
- }
64
+ function normalizeCandidate(candidate: string): NormalizedRef | null {
65
+ const canonical = normalizeToken(candidate);
66
+ if (!AKM_REF_STRICT.test(canonical)) return null;
67
+
68
+ const withoutFragment = canonical.split("#", 1)[0] ?? "";
69
+ const separator = withoutFragment.indexOf("//");
70
+ const conceptId = separator === -1 ? withoutFragment : withoutFragment.slice(separator + 2);
71
+ const segments = conceptId.split("/");
72
+ if (segments.some((segment) => !segment || segment === "." || segment === "..")) return null;
73
+
74
+ return { canonical, conceptId };
75
+ }
76
+
77
+ function isFile(file: string): boolean {
78
+ try {
79
+ return existsSync(file) && statSync(file).isFile();
80
+ } catch {
81
+ return false;
189
82
  }
190
- return false;
191
83
  }
192
84
 
193
- /**
194
- * Drop candidate tokens that obviously cannot resolve:
195
- * - Embedded shell expansion: `memory:$(cmd)`, `knowledge:${VAR}`
196
- * - ACP type notation: `agent::Type`
197
- * - Empty / placeholder slugs: single char, `**`, leading `/`, `~`, `http*`
198
- * - Remote origins other than `local//` (cannot be validated offline)
199
- */
200
- function normalizeCandidate(fullRef: string): { type: string; name: string } | null {
201
- if (fullRef.includes("$(") || fullRef.includes("${")) return null;
202
- if (fullRef.includes("::")) return null;
85
+ /** Resolve a concept ID under any local bundle root without invoking AKM. */
86
+ function conceptExistsInAnyBundle(conceptId: string, bundleRoots: readonly string[]): boolean {
87
+ for (const root of bundleRoots) {
88
+ if (!root) continue;
89
+ const resolvedRoot = path.resolve(root);
90
+ const directPath = path.resolve(resolvedRoot, conceptId);
91
+ if (directPath !== resolvedRoot && !directPath.startsWith(`${resolvedRoot}${path.sep}`)) continue;
203
92
 
204
- let ref = fullRef;
205
- if (ref.startsWith("local//")) {
206
- ref = ref.slice("local//".length);
207
- } else if (ref.includes("//")) {
208
- return null; // remote origin — cannot validate locally
93
+ if (isFile(directPath) || isFile(`${directPath}.md`)) return true;
94
+ if (isFile(path.join(directPath, "SKILL.md"))) return true;
95
+ if (conceptId.startsWith("memories/") && isFile(`${directPath}.derived.md`)) return true;
209
96
  }
210
-
211
- const colonIdx = ref.indexOf(":");
212
- if (colonIdx === -1) return null;
213
- const type = ref.slice(0, colonIdx);
214
- const name = ref.slice(colonIdx + 1);
215
- if (!name || name.startsWith("/") || name.startsWith("~") || name.startsWith("http")) return null;
216
- if (name.length <= 1 || name === "**") return null;
217
- // Slug must not contain shell metacharacters — pipe etc. would be a
218
- // pasted regex like `memory:foo|knowledge:bar`.
219
- if (/[|&;<>(){}\[\]"`'\\?*]/.test(name)) return null;
220
- return { type, name };
97
+ return false;
221
98
  }
222
99
 
223
- /**
224
- * Given an arbitrary block of text and one or more stash roots, return the
225
- * subset of `<type>:<slug>` tokens that actually resolve to a real asset
226
- * on disk. String literals (heredocs, grep patterns, jq queries) are
227
- * silently dropped — they don't exist in the stash and therefore cannot
228
- * generate `missing-ref` lint flags after the body is captured.
229
- *
230
- * The returned list is sorted alphabetically and deduplicated.
231
- */
232
- export function validateLiveRefs(text: string, stashRoots: readonly string[]): string[] {
233
- return validateRefCandidates(extractAllRefs(text), stashRoots);
100
+ /** Extract and retain only refs whose concept IDs exist in a local bundle. */
101
+ export function validateLiveRefs(text: string, bundleRoots: readonly string[]): string[] {
102
+ return validateRefCandidates(extractAllRefs(text), bundleRoots);
234
103
  }
235
104
 
236
105
  /**
237
- * Validate a pre-extracted list of candidate refs against one or more
238
- * stash roots. Returns the subset that resolve to a real on-disk asset,
239
- * sorted alphabetically and deduplicated. The input is typically produced
240
- * by `extractAllRefs(...)` on raw command/output text — the producer-side
241
- * collection step is permissive so candidates can be accumulated across
242
- * tool invocations and validated once at memory-capture time.
106
+ * Validate pre-extracted refs against local bundle roots. Bundle qualifiers and
107
+ * fragments are preserved in the result but do not alter local path lookup.
243
108
  */
244
- export function validateRefCandidates(candidates: readonly string[], stashRoots: readonly string[]): string[] {
109
+ export function validateRefCandidates(candidates: readonly string[], bundleRoots: readonly string[]): string[] {
245
110
  if (!candidates || candidates.length === 0) return [];
246
- const roots = stashRoots.filter(Boolean);
111
+ const roots = bundleRoots.filter(Boolean);
247
112
  if (roots.length === 0) return [];
248
- const seen = new Set<string>();
249
- const out: string[] = [];
113
+
114
+ const refs = new Set<string>();
250
115
  for (const candidate of candidates) {
251
- const norm = normalizeCandidate(candidate);
252
- if (!norm) continue;
253
- if (!refExistsInAnyStash(norm.type, norm.name, roots)) continue;
254
- const canonical = `${norm.type}:${norm.name}`;
255
- if (seen.has(canonical)) continue;
256
- seen.add(canonical);
257
- out.push(canonical);
116
+ const normalized = normalizeCandidate(candidate);
117
+ if (normalized && conceptExistsInAnyBundle(normalized.conceptId, roots)) refs.add(normalized.canonical);
258
118
  }
259
- out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
260
- return out;
119
+ return [...refs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
261
120
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Shared helpers for the append-only state files both plugins keep under their
3
+ * harness state dir (session.log, feedback.log, memory.log, quality-cache.tsv,
4
+ * sessions/<sid>.md, extract.log, events.jsonl).
5
+ *
6
+ * This module is the single copy of three primitives that were previously
7
+ * duplicated near-verbatim in claude/hooks/akm-hook.ts and ./memory-events.ts:
8
+ *
9
+ * chmodSafe() best-effort owner-only hardening that never throws
10
+ * atomicWriteFileSync() write-temp-then-rename, with temp cleanup on failure
11
+ * rotateIfOversized() size cap that keeps the newest half of the file
12
+ *
13
+ * Constraints this module must keep satisfying:
14
+ * - Zero third-party imports. claude/hooks/akm-hook.ts runs as a bare Bun
15
+ * script with no node_modules at hook-execution time, so only node:* builtins
16
+ * are importable here.
17
+ * - It is vendored into the published OpenCode tarball by
18
+ * opencode/scripts/vendor-shared.mjs, which copies every *.ts in
19
+ * claude/shared/ — no registration step is needed for a new file.
20
+ */
21
+
22
+ import { chmodSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
23
+
24
+ /**
25
+ * Best-effort chmod. State files can hold prompt fragments, ref names and
26
+ * (despite redaction) sensitive contextual data, so they are locked to
27
+ * owner-only. Filesystems and platforms without POSIX mode (Windows, FAT, some
28
+ * FUSE mounts) silently skip: a hardening attempt must never crash a hook.
29
+ */
30
+ export function chmodSafe(target: string, mode: number): void {
31
+ try {
32
+ chmodSync(target, mode)
33
+ } catch {
34
+ // Intentionally ignored — see the doc comment above.
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Write a file atomically: temp file in the same directory, then rename over
40
+ * the target. rename(2) is atomic on POSIX (and on Windows via Node's
41
+ * implementation), so a concurrent reader always observes either the old
42
+ * content in full or the new content in full — never a torn/partial write.
43
+ *
44
+ * A failed rename removes the temp file (nothing else ever prunes it) and then
45
+ * rethrows, so a caller keeps its own error semantics — rotateIfOversized(),
46
+ * the only caller today, swallows it.
47
+ */
48
+ export function atomicWriteFileSync(filePath: string, content: string, mode?: number): void {
49
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
50
+ writeFileSync(tmpPath, content)
51
+ if (mode !== undefined) chmodSafe(tmpPath, mode)
52
+ try {
53
+ renameSync(tmpPath, filePath)
54
+ } catch (error) {
55
+ // Don't orphan the temp file when the swap fails (EXDEV, permissions, a
56
+ // concurrent unlink of the target dir, ...).
57
+ try {
58
+ rmSync(tmpPath, { force: true })
59
+ } catch {}
60
+ throw error
61
+ }
62
+ }
63
+
64
+ // State-file rotation/caps (release-0.9.0 review §2 "Unbounded state growth"):
65
+ // every append-only file under the harness state dir previously grew without
66
+ // bound for the lifetime of the machine. AKM_PLUGIN_MAX_LOG_BYTES (default
67
+ // 1 MiB) is the shared cap for all of them.
68
+ const MAX_LOG_BYTES = (() => {
69
+ const raw = Number(process.env.AKM_PLUGIN_MAX_LOG_BYTES)
70
+ return Number.isFinite(raw) && raw > 0 ? raw : 1024 * 1024
71
+ })()
72
+
73
+ /**
74
+ * Before an append: if the file already exceeds AKM_PLUGIN_MAX_LOG_BYTES,
75
+ * rewrite it down to its newest half (by line count) via write-temp-then-rename
76
+ * so a concurrent reader/writer never observes a truncated or partial file.
77
+ *
78
+ * Best-effort and never throws: a failed attempt just means the file keeps
79
+ * growing until the next successful append/rotate.
80
+ */
81
+ export function rotateIfOversized(filePath: string): void {
82
+ try {
83
+ const stat = statSync(filePath)
84
+ if (stat.size <= MAX_LOG_BYTES) return
85
+ const lines = readFileSync(filePath, "utf8").split("\n")
86
+ if (lines[lines.length - 1] === "") lines.pop()
87
+ // Keep-at-least-one-line guard: a single line larger than the cap would
88
+ // make slice(ceil(len/2)) empty and rotation would erase the file instead
89
+ // of capping it. Always retain the newest line.
90
+ const keep = lines.length <= 1 ? lines : lines.slice(Math.ceil(lines.length / 2))
91
+ // 0o600 on the replacement: without it the rewritten file would come back
92
+ // at the umask default and quietly drop the owner-only posture the
93
+ // originals are created with.
94
+ atomicWriteFileSync(filePath, keep.length > 0 ? `${keep.join("\n")}\n` : "", 0o600)
95
+ } catch {
96
+ // Rotation is best-effort and must never throw — see the doc comment.
97
+ }
98
+ }