@mnmal-ai/cortext-opencode 1.0.0-alpha.6 → 1.0.0-alpha.8

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 CHANGED
@@ -18,7 +18,7 @@ OpenCode 1.x compat).
18
18
  "$schema": "https://opencode.ai/config.json",
19
19
  "plugins": [
20
20
  {
21
- "package": "@mnmal-ai/cortext-opencode",
21
+ "package": "@mnmal-ai/cortext-opencode@next",
22
22
  "options": {
23
23
  "kid": "synoptic-opencode@YourHost",
24
24
  "key": "/home/you/.config/substrate/keys/synoptic-opencode.key"
@@ -28,7 +28,7 @@ OpenCode 1.x compat).
28
28
  }
29
29
  ```
30
30
 
31
- The plain string form (`"plugins": ["@mnmal-ai/cortext-opencode"]`) also
31
+ The plain string form (`"plugins": ["@mnmal-ai/cortext-opencode@next"]`) also
32
32
  works — identity then comes from the env pair. **Keep exactly one entry
33
33
  per package**: as of opencode v2.0.24 the `plugins`
34
34
  arrays concatenate across global + project config and loads dedupe only
@@ -40,7 +40,7 @@ OpenCode 1.x compat).
40
40
  public on npmjs.com, as is the `@mnmal-ai/substrate` dep; no token needed.
41
41
 
42
42
  2. MCP server — merge into the same `opencode.json` (see
43
- `commands/substrate-setup.md` Step 4 for the full procedure). **The MCP server
43
+ `skills/substrate-setup/SKILL.md` Step 4 for the full procedure). **The MCP server
44
44
  must sign as the same identity as the plugin**: the plugin shows
45
45
  mail for its `options.kid`, and only that kid may ack it. With
46
46
  `options.kid`/`options.key`, give the MCP `environment` the same kid and
@@ -53,7 +53,7 @@ OpenCode 1.x compat).
53
53
  "servers": {
54
54
  "substrate": {
55
55
  "type": "local",
56
- "command": ["npx", "-y", "@mnmal-ai/substrate", "mcp"],
56
+ "command": ["npx", "-y", "@mnmal-ai/substrate@next", "mcp"],
57
57
  "codemode": false,
58
58
  "environment": {
59
59
  "SUBSTRATE_URL": "https://substrate.example.com:3100",
@@ -77,10 +77,18 @@ OpenCode 1.x compat).
77
77
  version. An older hand-copied `.opencode/substrate-conventions.md` is named in
78
78
  the session context as stale; delete it and its `"instructions"` entry.
79
79
 
80
- 4. Restart opencode (config loads once at startup). Or run `/substrate-setup`
81
- (copy `commands/substrate-setup.md` to `.opencode/commands/`) for the guided
82
- version of all of the above plus capability probing, AgentRule seeding,
83
- identity provisioning, and the first anchor.
80
+ 4. Restart opencode (config loads once at startup), then install the
81
+ skills. OpenCode loads no skills from a plugin package, so copy them to the
82
+ user skills directory, and again after each plugin update:
83
+
84
+ ```bash
85
+ PKG=$(ls -d ~/.cache/opencode/npm/@mnmal-ai/cortext-opencode@next/*/node_modules/@mnmal-ai/cortext-opencode | sort | tail -1)
86
+ mkdir -p ~/.config/opencode/skills && cp -R "$PKG/skills/." ~/.config/opencode/skills/
87
+ ```
88
+
89
+ The `substrate-setup` skill is the guided version of all of the above plus
90
+ capability probing, AgentRule seeding, identity provisioning, and the
91
+ first anchor.
84
92
 
85
93
  ## Environment
86
94
 
@@ -105,8 +113,9 @@ OpenCode 1.x compat).
105
113
  | `Stop` inbox check (blocks once) | V2: a `session.execution.succeeded` event with unread mail queues one synthetic message carrying the same reason, with `resume: true`. Each message id nudges once. `SYNOPTIC_INBOX_STOP_CHECK=off` disables it |
106
114
  | Mid-turn push (`inbox-bridge`) | V2: the watcher runs in-process; new mail is steered into every busy session, allowlisted urgent mail preempts the most recently prompted session, and idle wake is opt-in. While the watcher is live the per-prompt summary is skipped; the end-of-turn nudge stays |
107
115
  | `skills/{council,todos}` | `skills/{council,todos}` — byte-identical pointer bodies (`test/skills-sync.test.ts` enforces) |
116
+ | `skills/substrate-run` | `skills/substrate-run` — shared steps 1–5, own binary/identity and permission steps (`test/skills-sync.test.ts` enforces) |
108
117
  | `fragments/substrate-conventions.md` | `instructions/substrate-conventions.md` — shared body, own marker (`test/instructions-sync.test.ts` enforces) |
109
- | `/substrate-setup` skill | `commands/substrate-setup.md` — opencode paths + MCP dialect |
118
+ | `/substrate-setup` skill | `skills/substrate-setup` — opencode paths + MCP dialect |
110
119
  | SessionStart `compact` re-run | `compaction` session hook: drops the cache (next dispatch rebuilds) + leaves a pointer in the summary |
111
120
  | Fragment-drift watchdog | **Deferred** — the check reads the `.claude/` install path; see `src/plugin.ts` header |
112
121
 
package/dist/plugin.js CHANGED
@@ -49,7 +49,7 @@ function claimList(raw) {
49
49
  function b64url(buf) {
50
50
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
51
51
  }
52
- function mint(kid, key, ns, capabilities) {
52
+ function mint(kid, key, ns, capabilities, aud) {
53
53
  const now = Math.floor(Date.now() / 1e3);
54
54
  const exp = now + TTL_SECONDS;
55
55
  const signingInput = `${b64url(Buffer.from(JSON.stringify({
@@ -58,6 +58,7 @@ function mint(kid, key, ns, capabilities) {
58
58
  kid
59
59
  }), "utf8"))}.${b64url(Buffer.from(JSON.stringify({
60
60
  sub: kid,
61
+ aud,
61
62
  iat: now,
62
63
  exp,
63
64
  ns,
@@ -68,6 +69,23 @@ function mint(kid, key, ns, capabilities) {
68
69
  exp
69
70
  };
70
71
  }
72
+ const LOOPBACK = /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[::1\])$/;
73
+ /**
74
+ * The origin a token for `url` names as its `aud`. Throws rather than mint a
75
+ * token without one, or one bound for plain http to a non-loopback host, where
76
+ * the network can read it. Same rule as substrate-client's `tokenAudience`.
77
+ */
78
+ function audienceOf(url) {
79
+ const raw = typeof url === "object" && url !== null && "url" in url ? url.url : url;
80
+ let parsed;
81
+ try {
82
+ parsed = new URL(String(raw));
83
+ } catch {
84
+ throw new Error(`cannot mint an agent token without an audience: '${raw}' is not a URL`);
85
+ }
86
+ if (parsed.protocol === "https:" || parsed.protocol === "http:" && LOOPBACK.test(parsed.hostname)) return parsed.origin;
87
+ throw new Error(`refusing to send an agent token to ${parsed.origin}: use https, or plain http only to a loopback host`);
88
+ }
71
89
  function misconfigured(detail) {
72
90
  return {
73
91
  claim: null,
@@ -118,12 +136,18 @@ function createAgentAuth(env, opts) {
118
136
  const capabilities = opts.capabilities ?? claimList(env.SUBSTRATE_AGENT_CAPABILITIES) ?? DEFAULT_CAPABILITIES;
119
137
  const ns = opts.namespaces ?? claimList(env.SYNOPTIC_AGENT_NAMESPACES) ?? DEFAULT_NAMESPACES;
120
138
  if (capabilities.includes("*")) return misconfigured(`capabilities cannot be claimed as '*' — the capability gates do a bare \`includes\` with no wildcard branch, so a wildcard claim is refused everything. Enumerate them instead, e.g. SUBSTRATE_AGENT_CAPABILITIES=${DEFAULT_CAPABILITIES.join(",")}`);
121
- let cached = null;
139
+ const cached = /* @__PURE__ */ new Map();
122
140
  return {
123
- getAuthHeader: () => {
141
+ /** @param {string|URL|Request} [url] The request URL; its origin is the token's `aud`. */
142
+ getAuthHeader: (url = env.SUBSTRATE_URL) => {
143
+ const aud = audienceOf(url);
124
144
  const now = Math.floor(Date.now() / 1e3);
125
- if (cached === null || cached.exp - REFRESH_SKEW <= now) cached = mint(kid, key, ns, capabilities);
126
- return `Bearer ${cached.jwt}`;
145
+ let entry = cached.get(aud);
146
+ if (entry === void 0 || entry.exp - REFRESH_SKEW <= now) {
147
+ entry = mint(kid, key, ns, capabilities, aud);
148
+ cached.set(aud, entry);
149
+ }
150
+ return `Bearer ${entry.jwt}`;
127
151
  },
128
152
  warning: null,
129
153
  /** What this identity CLAIMS — the request, never the grant. See `probeWhoami` for the grant. */
@@ -140,10 +164,11 @@ function createAgentAuth(env, opts) {
140
164
  */
141
165
  function makeSignedFetch(getAuthHeader, fetchImpl = fetch) {
142
166
  return (url, init = {}) => {
143
- const header = getAuthHeader();
167
+ const header = getAuthHeader(url);
144
168
  if (header === null) return fetchImpl(url, init);
145
169
  return fetchImpl(url, {
146
170
  ...init,
171
+ redirect: "error",
147
172
  headers: {
148
173
  ...init.headers,
149
174
  authorization: header
@@ -2655,26 +2680,44 @@ async function runColdStart(opts) {
2655
2680
  }
2656
2681
  })();
2657
2682
  const rulesKey = `${namespace}/AgentRule`;
2658
- const rulesP = substrateQuery(substrateUrl, { [rulesKey]: {
2659
- params: {
2660
- where: {
2661
- ...projectWhere(projectSlug),
2662
- active: { eq: true }
2663
- },
2664
- limit: 200
2665
- },
2666
- fields: {
2667
- shortId: true,
2668
- category: true,
2669
- severity: true,
2670
- pinned: true,
2671
- synopsis: true
2683
+ const rulesP = (async () => {
2684
+ const rows = [];
2685
+ let cursor;
2686
+ for (let page = 0; page < 20; page++) {
2687
+ let pagination = {};
2688
+ const data = await substrateQuery(substrateUrl, { [rulesKey]: {
2689
+ params: {
2690
+ where: {
2691
+ ...projectWhere(projectSlug),
2692
+ active: { eq: true }
2693
+ },
2694
+ limit: 200,
2695
+ ...cursor !== void 0 ? { cursor } : {}
2696
+ },
2697
+ fields: {
2698
+ shortId: true,
2699
+ category: true,
2700
+ severity: true,
2701
+ pinned: true,
2702
+ synopsis: true
2703
+ }
2704
+ } }, {
2705
+ fetchImpl,
2706
+ onUnexpectedProjection: (entries) => projectionAudit.record(entries),
2707
+ onFrameFailure: (k, d) => projectionAudit.recordFailure(k, d),
2708
+ onPagination: (p) => {
2709
+ pagination = p;
2710
+ }
2711
+ });
2712
+ if (!(rulesKey in data)) return data;
2713
+ rows.push(...data[rulesKey]);
2714
+ const { hasMore, nextCursor } = pagination[rulesKey] ?? {};
2715
+ if (!hasMore) return { [rulesKey]: rows };
2716
+ if (nextCursor === void 0) throw new Error(`${rulesKey}: hasMore with no nextCursor`);
2717
+ cursor = nextCursor;
2672
2718
  }
2673
- } }, {
2674
- fetchImpl,
2675
- onUnexpectedProjection: (entries) => projectionAudit.record(entries),
2676
- onFrameFailure: (k, d) => projectionAudit.recordFailure(k, d)
2677
- });
2719
+ throw new Error(`${rulesKey}: more than 20 pages of rules`);
2720
+ })();
2678
2721
  const [data, inboxMd] = await Promise.all([rulesP, inboxP]);
2679
2722
  await hostReportP;
2680
2723
  const body = [
@@ -1,4 +1,4 @@
1
- <!-- substrate-conventions vsynoptic-1.0.0-alpha.6+9d0bf8e1 — opencode skin; plugin-owned, do not edit. Edit your own AGENTS.md instead. Body is shared with the Claude skin's fragment (test/instructions-sync.test.ts enforces it). -->
1
+ <!-- substrate-conventions vsynoptic-1.0.0-alpha.8+9d0bf8e1 — opencode skin; plugin-owned, do not edit. Edit your own AGENTS.md instead. Body is shared with the Claude skin's fragment (test/instructions-sync.test.ts enforces it). -->
2
2
 
3
3
  # Substrate interaction conventions
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnmal-ai/cortext-opencode",
3
- "version": "1.0.0-alpha.6",
3
+ "version": "1.0.0-alpha.8",
4
4
  "description": "Cortext — the OpenCode skin: cold-start injection via experimental.chat.system.transform, bundling the @mnmal-ai/synoptic host into a committed dist artifact. Installed as an OpenCode plugin (\"plugin\": [\"@mnmal-ai/cortext-opencode\"]). Published to npm, not the Claude marketplace.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -17,7 +17,6 @@
17
17
  "dist",
18
18
  "skills",
19
19
  "instructions",
20
- "commands",
21
20
  "README.md"
22
21
  ],
23
22
  "publishConfig": {
@@ -25,7 +24,7 @@
25
24
  "access": "public"
26
25
  },
27
26
  "devDependencies": {
28
- "@mnmal-ai/synoptic": "1.0.0-alpha.6",
27
+ "@mnmal-ai/synoptic": "1.0.0-alpha.8",
29
28
  "@opencode-ai/plugin": "^1.18.35",
30
29
  "@opencode/plugin": "^2.0.24",
31
30
  "@types/node": "^26.6.4",
@@ -0,0 +1,151 @@
1
+ ---
2
+ name: substrate-run
3
+ description: "Run a short TypeScript script against Substrate with `substrate run`, so many reads and writes cost one tool call and return one small result. Use for multi-step store work: censuses and counts, joins across types, recall-then-hydrate, and batch writes driven by reads. Do NOT use for a single lookup or a single write; the substrate MCP tools are simpler there. Requires substrate >= 1.0.0-alpha.6."
4
+ ---
5
+
6
+ `substrate run` imports one `.mts` script, calls its default export with a signed
7
+ `ScriptContext`, and prints one JSON report. Many queries and mutations run in a
8
+ single tool call, and only the result the script returns reaches your context.
9
+
10
+ ## 0. Find the binary and the identity
11
+
12
+ **Requires substrate >= 1.0.0-alpha.6.** Older binaries have no `run` subcommand.
13
+
14
+ Both come from the `substrate` server in `opencode.json` (`mcp.servers.substrate`,
15
+ project or user level):
16
+
17
+ - **The binary** is its `command` array minus the trailing `mcp`: either
18
+ `node /abs/path/to/substrate.js`, or `npx -y @mnmal-ai/substrate@next`.
19
+ - **The identity** is its `environment`: `SUBSTRATE_URL`,
20
+ `SUBSTRATE_AGENT_KEY_ID` and `SUBSTRATE_AGENT_KEY_PATH`. A `{env:VAR}` value
21
+ is already in your shell. A literal one is not, so set it on the command.
22
+ Use only the key's path; never read or print the key file.
23
+
24
+ ```bash
25
+ node /abs/path/to/substrate.js --version
26
+ ```
27
+
28
+ Write every run as one literal command, values filled in, leaving out any
29
+ variable your shell already has:
30
+
31
+ ```bash
32
+ env SUBSTRATE_URL=https://substrate.example.com:3100 SUBSTRATE_AGENT_KEY_ID=repo-opencode@host SUBSTRATE_AGENT_KEY_PATH=/abs/path/to/repo-opencode.key node /abs/path/to/substrate.js run script.mts
33
+ ```
34
+
35
+ If the version is below `1.0.0-alpha.6`, stop and use the MCP tools instead.
36
+ Say that the local substrate install is behind and that updating it unlocks
37
+ this skill.
38
+
39
+ **It refuses to run unsigned**, so a refusal names a missing identity rather
40
+ than a broken script.
41
+
42
+ ## 1. When to use it, and when not to
43
+
44
+ | Use `substrate run` | Use the MCP tools |
45
+ |---|---|
46
+ | A count or census over many rows (`queryAll` pages to the end) | One lookup by id, shortId or name |
47
+ | A join: rows of one type, then the rows they reference | One write |
48
+ | Recall, then fetch the bodies of the few hits worth reading | Exploring the schema |
49
+ | A batch of writes chosen by what a read found | Anything you will read only once, row by row |
50
+
51
+ The test: if you would otherwise make three or more MCP calls and pass ids from
52
+ one call to the next, write a script.
53
+
54
+ ## 2. Write the script outside the repo
55
+
56
+ Scripts are throwaway. Put them in the session scratchpad directory, or a temp
57
+ directory if the harness gives you none, never in the repo.
58
+
59
+ ```ts
60
+ import type { ScriptContext } from '@mnmal-ai/substrate/client';
61
+
62
+ // node /abs/path/to/substrate.js run open-by-project.mts --args '{"ns":"cortext"}'
63
+ export default async function (s: ScriptContext) {
64
+ const ns = String(s.args.ns ?? 'cortext');
65
+ const open = await s.queryAll(`${ns}/Todo`, {
66
+ params: { where: { status: { eq: 'open' }, archived: { eq: false } }, limit: 100 },
67
+ fields: { id: true, project: true }
68
+ });
69
+ const byProject: Record<string, number> = {};
70
+ for (const t of open) byProject[String(t.project ?? 'null')] = (byProject[String(t.project ?? 'null')] ?? 0) + 1;
71
+ return { total: open.length, byProject };
72
+ }
73
+ ```
74
+
75
+ The namespace is `cortext` unless `SUBSTRATE_NAMESPACE` says otherwise. Pass it
76
+ in through `--args` instead of hardcoding it. The `import type` line is erased
77
+ at load, so the script needs no `node_modules` beside it.
78
+
79
+ The context:
80
+
81
+ | Member | Does |
82
+ |---|---|
83
+ | `s.args` | The `--args` JSON object, frozen. `{}` when omitted |
84
+ | `s.query(frame)` | One query frame. **Throws if any key you asked for is missing from `data`**: a rejected frame fails the run with the server's `errors` instead of reading as empty. Adds a warning when a result is truncated (`hasMore`) |
85
+ | `s.queryAll(key, { params, fields })` | Every row of one key, paging until `hasMore` is false |
86
+ | `s.recall({ query, namespace, type?, k?, threshold?, where?, row? })` | Hits, best first. `row` holds `id` plus the handle fields; pass `row: { body: true }` (or `true`) only on the call whose hits you will read |
87
+ | `s.mutate(payload)` | One mutate payload. Throws by default, records without sending under `--dry-run`, sends under `--write`. Throws when the server refuses any frame |
88
+ | `s.log(...)` | stderr only. The report owns stdout |
89
+ | `s.kid`, `s.mode` | The signing kid and `read` / `dry-run` / `write` |
90
+
91
+ ## 3. Reduce in the script
92
+
93
+ Return counts, ids, short handles, a verdict. Never return whole rows "to look
94
+ at". Filtering, grouping and joining belong in the script, because what it
95
+ returns lands in your context.
96
+
97
+ When the result is large, pass `--out <file>` (beside the script). The report
98
+ goes to the file and stdout gets one line. Then read only what you need from
99
+ the file.
100
+
101
+ ## 4. Writes: dry run, read, then write
102
+
103
+ The default mode is read-only, and `s.mutate` throws in it.
104
+
105
+ 1. Run with `--dry-run`. Each payload is recorded in `report.mutations` and
106
+ nothing is sent; `s.mutate` returns `null`, so do not let later logic depend
107
+ on its return value.
108
+ 2. Read `report.mutations`. Check the count, the ids, and a sample of the
109
+ params against what you intended.
110
+ 3. Re-run the same script with `--write`. It sends up to `--max-writes` frames
111
+ (default 50; an array under an op-key counts one per element). Raise it
112
+ deliberately, not by habit.
113
+
114
+ The script re-reads the store on the write run, so it acts on the current state
115
+ rather than on what the dry run saw.
116
+
117
+ Transactions are per call and per op-key. Within one `s.mutate` payload, each
118
+ array op-key is its own transaction and the single-frame op-keys share one, so
119
+ one op-key can be refused (for example `policy_denied`) while its siblings
120
+ land. Separate `s.mutate` calls are separate transactions. For writes that must
121
+ land together, put them in one array under one op-key.
122
+
123
+ ## 5. Read the report
124
+
125
+ ```json
126
+ { "ok": true, "kid": "…", "mode": "write", "args": {}, "result": {}, "mutations": [{ "frame": {}, "applied": true }], "warnings": [], "stats": { "queries": 3, "writes": 1, "ms": 137 } }
127
+ ```
128
+
129
+ - Check `ok` and the exit code (`0` ok, `1` the script failed, `2` usage or
130
+ configuration) before `result`. On failure `error` carries the server's
131
+ message.
132
+ - **`mutations` lists every payload with `applied`, including those sent before
133
+ a failure.** A failed `--write` run may have written part of its batch. Read
134
+ this list before retrying, and make a retry skip rows that already changed.
135
+ - `warnings` names truncated reads. A warning means the answer is incomplete;
136
+ switch that read to `queryAll`.
137
+
138
+ Never pipe the run into `tail` or `jq` when its exit code matters. Use
139
+ `--out`, or redirect to a file.
140
+
141
+ ## 6. Cut the permission prompts
142
+
143
+ If OpenCode asks before each run, suggest the user allow the command in
144
+ `opencode.json`, using the prefix from step 0:
145
+
146
+ ```json
147
+ { "permission": { "bash": { "env SUBSTRATE_URL=* node /abs/path/to/substrate.js run *": "allow" } } }
148
+ ```
149
+
150
+ This covers `--write` too, just as the MCP tools write without prompting. The
151
+ dry run in step 4 is the guard on writes, not the permission prompt.
@@ -1,10 +1,11 @@
1
1
  ---
2
+ name: substrate-setup
2
3
  description: Onboard this repo as a Cortext consumer under OpenCode. Probes the target Substrate, wires the substrate MCP server in opencode.json (identity-ready), installs the conventions instructions, and seeds baseline AgentRules. Run once per repo.
3
4
  ---
4
5
 
5
- # /substrate-setup (OpenCode) — onboard a cortext-opencode consumer
6
+ # substrate-setup (OpenCode): onboard a cortext-opencode consumer
6
7
 
7
- Run these steps in order. This command is **idempotent** — re-running it never duplicates config entries or rules.
8
+ Run these steps in order. This skill is **idempotent** — re-running it never duplicates config entries or rules.
8
9
 
9
10
  Steps 2, 5, 6, 8 are transport-agnostic and identical to the Claude skin's
10
11
  `/substrate-setup`; only the file paths and the MCP dialect differ. Where this
@@ -51,7 +52,7 @@ following and report it as ✓/✗:
51
52
  3. **Recall capability:** `GET <SUBSTRATE_URL>/substrate/v1/embed-stats` → `enabled`.
52
53
  `true` = hybrid cold-start; `false` = structural-only (degrades cleanly).
53
54
  4. **MCP package resolvable** (only if Step 4 uses the npx server form):
54
- `npm view @mnmal-ai/substrate version`. It is public on npmjs.com, so a
55
+ `npm view @mnmal-ai/substrate@next version`. It is public on npmjs.com, so a
55
56
  failure here is a network or registry-mirror problem, not missing auth.
56
57
 
57
58
  Print a capability summary block.
@@ -67,7 +68,7 @@ Print a capability summary block.
67
68
  "$schema": "https://opencode.ai/config.json",
68
69
  "plugins": [
69
70
  {
70
- "package": "@mnmal-ai/cortext-opencode",
71
+ "package": "@mnmal-ai/cortext-opencode@next",
71
72
  "options": {
72
73
  "kid": "<the Step-1 kid>",
73
74
  "key": "<the Step-1 key file>"
@@ -77,7 +78,7 @@ Print a capability summary block.
77
78
  }
78
79
  ```
79
80
 
80
- The plain string form (`"plugins": ["@mnmal-ai/cortext-opencode"]`) also
81
+ The plain string form (`"plugins": ["@mnmal-ai/cortext-opencode@next"]`) also
81
82
  works — identity then comes from the env pair. **Keep exactly one entry
82
83
  per package**: as of opencode v2.0.24 the `plugins`
83
84
  arrays concatenate across global + project config and loads dedupe only
@@ -89,7 +90,19 @@ Print a capability summary block.
89
90
  npm plugins install automatically via Bun at startup (cached under
90
91
  `~/.cache/opencode/`). No registry auth is needed.
91
92
 
92
- 2. Conventions need no step: the plugin injects them from its own package
93
+ 2. Install the skills. OpenCode loads no skills from a plugin package, so
94
+ copy them to the user skills directory, where every repo sees them. Bun
95
+ installs the plugin at startup, so restart opencode after step 1 first.
96
+ Re-run this after each plugin update; the copy does not track the package.
97
+
98
+ ```bash
99
+ PKG=$(ls -d ~/.cache/opencode/npm/@mnmal-ai/cortext-opencode@next/*/node_modules/@mnmal-ai/cortext-opencode | sort | tail -1)
100
+ mkdir -p ~/.config/opencode/skills && cp -R "$PKG/skills/." ~/.config/opencode/skills/
101
+ ```
102
+
103
+ That installs `council`, `todos`, `substrate-run` and this skill.
104
+
105
+ 3. Conventions need no step: the plugin injects them from its own package
93
106
  on every dispatch, so they track the installed version. If the
94
107
  repo has an older `.opencode/substrate-conventions.md` copy, delete it and its
95
108
  `"instructions"` entry; the session context names it while it is stale.
@@ -115,7 +128,7 @@ leaves empty:
115
128
  "servers": {
116
129
  "substrate": {
117
130
  "type": "local",
118
- "command": ["npx", "-y", "@mnmal-ai/substrate", "mcp"],
131
+ "command": ["npx", "-y", "@mnmal-ai/substrate@next", "mcp"],
119
132
  "codemode": false,
120
133
  "environment": {
121
134
  "SUBSTRATE_URL": "<the url>",
@@ -212,6 +225,6 @@ set):
212
225
  ```
213
226
 
214
227
  Then summarise: SUBSTRATE_URL source, plugin entry, instructions copy, MCP
215
- server form, rules created vs skipped, recall tier, identity (per-project
228
+ server form, skills installed, rules created vs skipped, recall tier, identity (per-project
216
229
  kid vs fleet default). Tell the user to restart opencode — config loads once
217
230
  at startup, and the cold-start injects on the first dispatch after that.