akm-opencode 0.8.1 → 0.9.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/README.md +39 -239
- package/index.ts +699 -2469
- package/package.json +10 -8
- package/shared/akm-version.ts +45 -0
- package/shared/memory-candidates.ts +19 -15
- package/shared/memory-events.ts +7 -11
- package/shared/ref-extraction.ts +75 -216
- package/shared/state-files.ts +99 -0
- package/shared/vendor-semver.ts +112 -0
- package/agent/akm-curator.md +0 -66
- package/commands/akm-evolve-session.md +0 -15
- package/commands/akm-improve-asset.md +0 -9
- package/commands/akm-propose-asset.md +0 -8
- package/commands/akm-review-proposals.md +0 -8
- package/commands/akm-workflow-status.md +0 -7
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-opencode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "OpenCode plugin for AKM
|
|
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.
|
|
43
|
-
"akm-cli": "^0.
|
|
44
|
-
|
|
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
|
+
}
|
|
@@ -1,21 +1,16 @@
|
|
|
1
|
-
import { appendFileSync,
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { redactObject } from "./redaction"
|
|
4
|
-
|
|
5
4
|
// Memory candidates can contain prompt fragments, ref names, and (despite
|
|
6
5
|
// redaction) potentially sensitive contextual data harvested from session
|
|
7
|
-
// activity.
|
|
6
|
+
// activity. The on-disk file is locked to user-only read/write so multi-user
|
|
8
7
|
// hosts (CI runners, shared VMs, dev sandboxes) cannot side-read another
|
|
9
|
-
// user's stash signals.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// platforms where chmod is a no-op (Windows) silently skip. Don't crash
|
|
16
|
-
// the hook over a hardening attempt.
|
|
17
|
-
}
|
|
18
|
-
}
|
|
8
|
+
// user's stash signals, and memory-candidates.jsonl is size-capped like every
|
|
9
|
+
// other append-only state file. Both primitives — plus the temp+rename write
|
|
10
|
+
// used by replaceCandidates() (13: "Non-atomic candidate updates") — live in
|
|
11
|
+
// ./state-files so the Claude hook, ./memory-events and this module share one
|
|
12
|
+
// implementation.
|
|
13
|
+
import { atomicWriteFileSync, chmodSafe, rotateIfOversized } from "./state-files"
|
|
19
14
|
|
|
20
15
|
export type AkmMemoryCandidate = {
|
|
21
16
|
id: string
|
|
@@ -150,6 +145,7 @@ export function appendCandidates(filePath: string, candidates: AkmMemoryCandidat
|
|
|
150
145
|
try {
|
|
151
146
|
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
152
147
|
chmodSafe(path.dirname(filePath), 0o700)
|
|
148
|
+
rotateIfOversized(filePath)
|
|
153
149
|
const categories: string[] = []
|
|
154
150
|
const created = !existsSync(filePath)
|
|
155
151
|
for (const candidate of candidates) {
|
|
@@ -181,8 +177,16 @@ export function readCandidates(filePath: string): AkmMemoryCandidate[] {
|
|
|
181
177
|
export function replaceCandidates(filePath: string, candidates: AkmMemoryCandidate[]): void {
|
|
182
178
|
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
183
179
|
chmodSafe(path.dirname(filePath), 0o700)
|
|
184
|
-
|
|
185
|
-
|
|
180
|
+
// Atomic rewrite (13: "Non-atomic candidate updates" — see
|
|
181
|
+
// atomicWriteFileSync above). updateCandidateStatus() is a
|
|
182
|
+
// read-modify-write over the whole file; without temp+rename, a second
|
|
183
|
+
// hook process's appendCandidates() (a plain appendFileSync) landing
|
|
184
|
+
// between this read and this write would be silently overwritten by this
|
|
185
|
+
// rewrite once it lands, because writeFileSync truncates in place.
|
|
186
|
+
// temp+rename doesn't fully eliminate that read-modify-write race (true
|
|
187
|
+
// fix would need a lock), but it does guarantee the file itself is never
|
|
188
|
+
// observed half-written / truncated by a concurrent reader.
|
|
189
|
+
atomicWriteFileSync(filePath, candidates.map((candidate) => `${JSON.stringify(candidate)}\n`).join(""), 0o600)
|
|
186
190
|
}
|
|
187
191
|
|
|
188
192
|
export function updateCandidateStatus(filePath: string, id: string, status: "promoted" | "rejected", reason?: string): AkmMemoryCandidate | undefined {
|
package/shared/memory-events.ts
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
|
-
import { appendFileSync,
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } 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
|
|
7
|
-
// to owner-only
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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, this module and ./memory-candidates share one implementation.
|
|
9
|
+
import { chmodSafe, rotateIfOversized } from "./state-files"
|
|
15
10
|
|
|
16
11
|
export type AkmMemoryEventType =
|
|
17
12
|
| "session_started"
|
|
@@ -90,6 +85,7 @@ export function appendMemoryEvent(filePath: string, event: AkmMemoryEvent): { ok
|
|
|
90
85
|
try {
|
|
91
86
|
mkdirSync(path.dirname(filePath), { recursive: true })
|
|
92
87
|
chmodSafe(path.dirname(filePath), 0o700)
|
|
88
|
+
rotateIfOversized(filePath)
|
|
93
89
|
const redacted = redactObject(event)
|
|
94
90
|
const enriched = {
|
|
95
91
|
...redacted.value,
|
package/shared/ref-extraction.ts
CHANGED
|
@@ -1,89 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AKM ref extraction
|
|
2
|
+
* AKM 0.9 ref extraction and local bundle validation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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@._
|
|
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@._
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
|
238
|
-
*
|
|
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[],
|
|
109
|
+
export function validateRefCandidates(candidates: readonly string[], bundleRoots: readonly string[]): string[] {
|
|
245
110
|
if (!candidates || candidates.length === 0) return [];
|
|
246
|
-
const roots =
|
|
111
|
+
const roots = bundleRoots.filter(Boolean);
|
|
247
112
|
if (roots.length === 0) return [];
|
|
248
|
-
|
|
249
|
-
const
|
|
113
|
+
|
|
114
|
+
const refs = new Set<string>();
|
|
250
115
|
for (const candidate of candidates) {
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
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
|
-
|
|
260
|
-
return out;
|
|
119
|
+
return [...refs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
261
120
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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, memory-candidates.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, ./memory-events.ts and
|
|
8
|
+
* ./memory-candidates.ts:
|
|
9
|
+
*
|
|
10
|
+
* chmodSafe() best-effort owner-only hardening that never throws
|
|
11
|
+
* atomicWriteFileSync() write-temp-then-rename, with temp cleanup on failure
|
|
12
|
+
* rotateIfOversized() size cap that keeps the newest half of the file
|
|
13
|
+
*
|
|
14
|
+
* Constraints this module must keep satisfying:
|
|
15
|
+
* - Zero third-party imports. claude/hooks/akm-hook.ts runs as a bare Bun
|
|
16
|
+
* script with no node_modules at hook-execution time, so only node:* builtins
|
|
17
|
+
* are importable here.
|
|
18
|
+
* - It is vendored into the published OpenCode tarball by
|
|
19
|
+
* opencode/scripts/vendor-shared.mjs, which copies every *.ts in
|
|
20
|
+
* claude/shared/ — no registration step is needed for a new file.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { chmodSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Best-effort chmod. State files can hold prompt fragments, ref names and
|
|
27
|
+
* (despite redaction) sensitive contextual data, so they are locked to
|
|
28
|
+
* owner-only. Filesystems and platforms without POSIX mode (Windows, FAT, some
|
|
29
|
+
* FUSE mounts) silently skip: a hardening attempt must never crash a hook.
|
|
30
|
+
*/
|
|
31
|
+
export function chmodSafe(target: string, mode: number): void {
|
|
32
|
+
try {
|
|
33
|
+
chmodSync(target, mode)
|
|
34
|
+
} catch {
|
|
35
|
+
// Intentionally ignored — see the doc comment above.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write a file atomically: temp file in the same directory, then rename over
|
|
41
|
+
* the target. rename(2) is atomic on POSIX (and on Windows via Node's
|
|
42
|
+
* implementation), so a concurrent reader always observes either the old
|
|
43
|
+
* content in full or the new content in full — never a torn/partial write.
|
|
44
|
+
*
|
|
45
|
+
* A failed rename removes the temp file (nothing else ever prunes it) and then
|
|
46
|
+
* rethrows, so each caller keeps its own error semantics: rotateIfOversized()
|
|
47
|
+
* swallows the error, memory-candidates' replaceCandidates() propagates it.
|
|
48
|
+
*/
|
|
49
|
+
export function atomicWriteFileSync(filePath: string, content: string, mode?: number): void {
|
|
50
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
|
|
51
|
+
writeFileSync(tmpPath, content)
|
|
52
|
+
if (mode !== undefined) chmodSafe(tmpPath, mode)
|
|
53
|
+
try {
|
|
54
|
+
renameSync(tmpPath, filePath)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// Don't orphan the temp file when the swap fails (EXDEV, permissions, a
|
|
57
|
+
// concurrent unlink of the target dir, ...).
|
|
58
|
+
try {
|
|
59
|
+
rmSync(tmpPath, { force: true })
|
|
60
|
+
} catch {}
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// State-file rotation/caps (release-0.9.0 review §2 "Unbounded state growth"):
|
|
66
|
+
// every append-only file under the harness state dir previously grew without
|
|
67
|
+
// bound for the lifetime of the machine. AKM_PLUGIN_MAX_LOG_BYTES (default
|
|
68
|
+
// 1 MiB) is the shared cap for all of them.
|
|
69
|
+
const MAX_LOG_BYTES = (() => {
|
|
70
|
+
const raw = Number(process.env.AKM_PLUGIN_MAX_LOG_BYTES)
|
|
71
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 1024 * 1024
|
|
72
|
+
})()
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Before an append: if the file already exceeds AKM_PLUGIN_MAX_LOG_BYTES,
|
|
76
|
+
* rewrite it down to its newest half (by line count) via write-temp-then-rename
|
|
77
|
+
* so a concurrent reader/writer never observes a truncated or partial file.
|
|
78
|
+
*
|
|
79
|
+
* Best-effort and never throws: a failed attempt just means the file keeps
|
|
80
|
+
* growing until the next successful append/rotate.
|
|
81
|
+
*/
|
|
82
|
+
export function rotateIfOversized(filePath: string): void {
|
|
83
|
+
try {
|
|
84
|
+
const stat = statSync(filePath)
|
|
85
|
+
if (stat.size <= MAX_LOG_BYTES) return
|
|
86
|
+
const lines = readFileSync(filePath, "utf8").split("\n")
|
|
87
|
+
if (lines[lines.length - 1] === "") lines.pop()
|
|
88
|
+
// Keep-at-least-one-line guard: a single line larger than the cap would
|
|
89
|
+
// make slice(ceil(len/2)) empty and rotation would erase the file instead
|
|
90
|
+
// of capping it. Always retain the newest line.
|
|
91
|
+
const keep = lines.length <= 1 ? lines : lines.slice(Math.ceil(lines.length / 2))
|
|
92
|
+
// 0o600 on the replacement: without it the rewritten file would come back
|
|
93
|
+
// at the umask default and quietly drop the owner-only posture the
|
|
94
|
+
// originals are created with.
|
|
95
|
+
atomicWriteFileSync(filePath, keep.length > 0 ? `${keep.join("\n")}\n` : "", 0o600)
|
|
96
|
+
} catch {
|
|
97
|
+
// Rotation is best-effort and must never throw — see the doc comment.
|
|
98
|
+
}
|
|
99
|
+
}
|