@rayul/kordis 0.15.2 → 0.16.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.
@@ -2,7 +2,7 @@
2
2
  name: memgineering
3
3
  description: Use whenever the user refers to something they told you before, asks what was decided, tells you something worth keeping, or settles something that should hold next time. The memory lives in their own folder and outlives this session; check it before answering from guesswork, and write to it when you learn something durable.
4
4
  type: skill
5
- version: 0.26.0
5
+ version: 0.27.0
6
6
  ---
7
7
 
8
8
  # memgineering
@@ -20,19 +20,46 @@ Publish a valid, AI-review-passing version. ALWAYS dry-run first.
20
20
  - `license` — SPDX (e.g. `MIT`, `Apache-2.0`, `CC-BY-4.0`).
21
21
  - Optional: `homepage`, `tags`, `type`.
22
22
 
23
- **`type` auto-inference (0.6.4+):** The CLI infers `type: skill` when
24
- you push a directory bundle, a `.skill` ZIP, OR a single file named
25
- `SKILL.md` or `*.skill.md`. You don't need to write `type:` in the
26
- frontmatter for any of these paths. Use `--type <t>` (or write the
27
- field manually) only when pushing a different package type from a
28
- single `.md` file that doesn't match the SKILL.md filename pattern.
23
+ **`type` and `--type`:** A package is published as the type its
24
+ frontmatter's `type:` says, and nothing else. When the frontmatter has
25
+ no `type:` line, the CLI writes one in: `type: skill` for a directory
26
+ bundle, a `.skill` ZIP, or a single file named `SKILL.md` or
27
+ `*.skill.md` — so none of these needs `type:` written — or `type: <t>`
28
+ when you pass `--type <t>`, on any path. When the frontmatter names a
29
+ type, `--type` must say the same: a different value is refused before
30
+ anything is sent (`usage_error`, exit 2, in `--dry-run` too). Change the
31
+ frontmatter's `type:`, or drop `--type`.
32
+
33
+ Nothing is written over a `type:` line that is already there, or into a
34
+ file that begins with a byte-order mark. These fail instead, in
35
+ `--dry-run` too; write `type: <t>` in by hand:
36
+
37
+ - `type:` with no value (or `null`, a number, a list), and a file with a
38
+ byte-order mark and no `type:` line: `missing_required_frontmatter`
39
+ (exit 30), with or without `--type`.
40
+ - `type: ""`, an empty string: without `--type` it fails as
41
+ `unknown_type` (exit 30); with `--type` it disagrees, so it is refused
42
+ before anything is sent (`usage_error`, exit 2).
43
+
44
+ **Older CLIs (0.15.2 and earlier)** send `--type` only beside the
45
+ content and never write it in. They fill in `type: skill` themselves
46
+ when the frontmatter names no type: for a folder or `.skill` bundle
47
+ from 0.1.12, and for a single `SKILL.md` or `*.skill.md` from 0.6.4.
48
+ The registry refuses a `--type` that differs from the type it reads —
49
+ written or filled in — with `usage_error` (exit 2) and a hint naming
50
+ that type. From 0.2.2 their `--dry-run` checks the file locally and
51
+ sends no publish request (from 0.9.1, `--bump minor` or `major` makes
52
+ it look the package up), so it does not show this refusal; only a real
53
+ push does. 0.2.1 and earlier send the dry run to the registry, which
54
+ refuses it the same way as a real push. Writing `type:` in the
55
+ frontmatter works on every version.
29
56
 
30
57
  **`version` field handling:**
31
58
 
32
59
  - **First publish** — used as-is (else defaults to `0.1.0`).
33
60
  - **Update** — the registry computes the next version from
34
61
  `current_version + --bump <strategy>` (default `patch`). If the file's
35
- `version` differs from that result, the API rejects with `usage`. Either
62
+ `version` differs from that result, the API rejects with `usage_error`. Either
36
63
  drop the `version` field on updates, set `--bump` to match, or align
37
64
  them.
38
65
  - RECOMMENDED: **omit `version:` from frontmatter on update pushes** and
@@ -70,8 +97,11 @@ get rejected. Dry-run first like any push.
70
97
  ## Recording lineage (0.12.0+)
71
98
 
72
99
  When a package descends from another (especially a judgment you adapted), record
73
- the provenance on `push` so it shows up in the "Derived from / Adapted by" graph,
74
- feeds the `community-adapted` trust tier, and gives a small trending boost:
100
+ the provenance on `push`. It shows up in the "Derived from / Adapted by" graph,
101
+ and when the derivative is another author's — public, with its current version
102
+ passed review — it also feeds the source's `community-adapted` trust tier and
103
+ gives the source a small trending boost. A derivative of a package you authored
104
+ yourself does neither. Record it with one of these flags:
75
105
 
76
106
  | flag | edge kind | meaning |
77
107
  | ----------------------- | ------------ | ----------------------------------------------- |
@@ -82,10 +112,10 @@ feeds the `community-adapted` trust tier, and gives a small trending boost:
82
112
 
83
113
  A source that does not resolve — a typo'd slug, or a package that is
84
114
  unpublished, deleted or private — **refuses the whole publish** with
85
- `usage_error` (exit 2), and `--dry-run` refuses it the same way, so a dry run
86
- catches a typo before anything is written. Fix the slug, or publish the source
87
- first, then push again. (Naming your own package as its source is skipped
88
- instead, and listed in the response's `lineage.skipped`.) Declare
115
+ `usage_error` (exit 2), and nothing is written. `--dry-run` does not look
116
+ sources up, so a typo first shows on the real push. Fix the slug, or publish
117
+ the source first, then push again. (Naming your own package as its source is
118
+ skipped instead, and listed in the response's `lineage.skipped`.) Declare
89
119
  lineage honestly; it is the registry's trust signal, not decoration.
90
120
 
91
121
  ## Yanking a bad version (0.10.0+)
@@ -163,7 +193,7 @@ operator restores it.
163
193
  | `--dry-run` | validate only; do not publish |
164
194
  | `--bump <strategy>` | `patch` (default) / `minor` / `major` — only on update |
165
195
  | `--slug <slug>` | override slug on first publish |
166
- | `--type <type>` | override type inference |
196
+ | `--type <type>` | fills a missing `type:`; must match an existing one |
167
197
  | `--changelog <text>` | optional changelog entry attached to this version |
168
198
  | `--no-wait` | return immediately without waiting for AI review |
169
199
  | `--adapted-from <slug>` / `--forked-from` / `--absorbs` / `--remixes` | record lineage (see "Recording lineage") |
@@ -174,29 +204,58 @@ operator restores it.
174
204
 
175
205
  ### 1. Dry-run
176
206
 
177
- `kordis push <file> --dry-run --json`. Returns the same validation, conflict,
178
- and version-resolution verdicts a real push would produce.
179
-
180
- CAVEAT: dry-run does NOT exercise the AI reviewer — it short-circuits with
181
- a synthetic `review.result: "passed"` (`prompt_version: "stub-v0"`). Treat
182
- dry-run as syntactic + structural validation only. A real push routes
183
- through the AI review worker, which can land terminal `passed` /
184
- `rejected`, OR leave the publish in `status: "pending"` if the worker
185
- hasn't decided within the wait window.
186
-
187
- **Success criteria**: dry-run returns a `status: "published"`-shaped envelope
188
- with no error flags.
189
-
190
- ### 2. Resolve flags
191
-
192
- Possible verdicts:
193
-
194
- - `validation_failed` (exit 30) — fix every listed flag, re-dry-run.
207
+ `kordis push <file> --dry-run --json` runs on this machine (0.2.2+): it
208
+ sends no publish request and needs no sign-in. It validates the content
209
+ the real push would send — the frontmatter, including any `type:` the CLI
210
+ writes in, the body, and bundled files' paths and sizes — and answers
211
+ `{status, type, tier, capabilities, asset_count, flags, warnings, note}`
212
+ with `status` `dry_run_passed` (exit 0) or `dry_run_failed` (exit 30).
213
+ A pass can still list `flags` that do not block. `warnings` come from a
214
+ local scan for content that looks private — `ipv4_or_internal_host`,
215
+ `credential_like_string`, `absolute_home_path`,
216
+ `first_person_context_hint` — and never block.
217
+
218
+ It decides nothing the registry decides: no name conflict, no version (a
219
+ frontmatter `version` is not checked against the bump), no lineage
220
+ source, no declared-capability check, no rate limit, and no AI review.
221
+ Apart from finishing a pending `kordis login --emit-only`, as every
222
+ command does, its one registry call is a package lookup for
223
+ `--bump minor` or `--bump major` (0.9.1+), made when it can tell which
224
+ package: the frontmatter has a `name`, and you are signed in or `--slug`
225
+ names `<user>/<slug>`. A bump on a package that does not exist yet is
226
+ refused — `usage_error`, exit 2, from 0.9.2 (0.9.1 exits 1) — and if the
227
+ lookup fails any other way, the check is skipped. Only a real push gets
228
+ the registry's verdicts, including the AI review, which can land
229
+ `passed` / `rejected`, or leave the publish `status: "pending"` if the
230
+ worker hasn't decided within the wait window.
231
+
232
+ **Success criteria**: `status: "dry_run_passed"`.
233
+
234
+ ### 2. Resolve what comes back
235
+
236
+ From the dry run:
237
+
238
+ - `dry_run_failed` (exit 30) — fix every listed flag, dry-run again.
239
+ - `usage_error` (exit 2) — a `--type` the frontmatter contradicts, or
240
+ `--bump minor` / `major` on a first publish; the message says which.
241
+ - `generic_failure` (exit 1) with a YAML hint — the frontmatter does not
242
+ parse; fix it as the hint says.
243
+ - `generic_failure` (exit 1) — the path is not a package the CLI can read:
244
+ it does not exist, a folder has no SKILL.md at its top, or a `.skill`
245
+ archive is broken or has no SKILL.md; the message says which.
246
+
247
+ Only from the real push (step 3), however clean the dry run was:
248
+
249
+ - `validation_failed` (exit 30) — the registry's checks: the dry run's,
250
+ plus ones it does not make, such as a `hook` or `mcp-server` that
251
+ declares `executable: false`; fix every listed flag.
252
+ - `usage_error` (exit 2) — for example a lineage source that does not
253
+ resolve, or a frontmatter `version` the bump does not produce; the
254
+ message says which. Nothing is written.
195
255
  - `conflict` (exit 40) — slug taken; suggest a different slug (or pass
196
256
  `--slug` if first publish).
197
- - Review rejection — never from a dry-run, which does not run the reviewer.
198
- On the real push (step 3) a rejection is not an error: the push exits 0
199
- and returns `status: "rejected"` with the reasons in `review.flags`
257
+ - Review rejection — not an error: the push exits 0 and returns
258
+ `status: "rejected"` with the reasons in `review.flags`
200
259
  (`ai_review_rejected`, exit 32, is defined but never sent). EXPLAIN the
201
260
  rejection to the user. Do NOT silently retry. Includes deterministic
202
261
  Stage-1 hits like `curl_pipe_shell`, `aws_access_key`,
@@ -204,7 +263,7 @@ Possible verdicts:
204
263
  - `rate_limited` (exit 31) — wait `retry_after_seconds`.
205
264
  - `not_authenticated` (exit 10) — `kordis login`, then retry.
206
265
 
207
- **Success criteria**: dry-run is clean.
266
+ **Success criteria**: the dry run passed.
208
267
 
209
268
  ### 3. Real push
210
269
 
@@ -219,9 +278,10 @@ yet — re-check with `kordis info <full_slug> --json` (or wait and re-run
219
278
  ## Versioning your push (0.9.0+)
220
279
 
221
280
  - **First publish** derives the version from the frontmatter `version:`
222
- field if present, else defaults to `0.1.0`. Passing `--bump` on a
223
- first publish now returns a clear `usage_error` (pre-0.9 silently
224
- ignored it) — drop `--bump` or set the frontmatter version explicitly.
281
+ field if present, else defaults to `0.1.0`. Passing `--bump minor` or
282
+ `--bump major` on a first publish now returns a clear `usage_error`
283
+ (pre-0.9 silently ignored it); `--bump patch`, the default, is ignored
284
+ there — drop `--bump` or set the frontmatter version explicitly.
225
285
  - **Subsequent pushes** default to `--bump patch`. Explicit
226
286
  `--bump minor` or `--bump major` ships the matching jump.
227
287
  - **Frontmatter `version:` on an update** must match the result of
@@ -249,19 +309,21 @@ silently retry — explain why.
249
309
  User (push-back after adapting a judgment): "that decision rule we adapted from
250
310
  <upstream/slug> worked well — share our version back"
251
311
  1. author the refitted judgment as a judgment-pattern file (structured
252
- frontmatter; see "Publishing a judgment-pattern")
312
+ frontmatter with `type: judgment-pattern`, which the `--type` below
313
+ must match; see "Publishing a judgment-pattern")
253
314
  2. kordis push ./judgment.md --type judgment-pattern --adapted-from <upstream/slug> --dry-run --json
254
315
  3. clean → drop --dry-run for the real push
255
316
  4. the response's `lineage.recorded` confirms the edge. A wrong or unpublished
256
- upstream slug never gets this far: step 2's dry run already refuses it with
257
- `usage_error` — fix the slug, or publish the source first. The edge gives the
258
- upstream `community-adapted` credit and shows in its "Adapted by" graph.
317
+ upstream slug is refused here, by the real push, with `usage_error` and
318
+ nothing written — the dry run in step 2 does not look sources up. Fix the
319
+ slug, or publish the source first. The edge gives the upstream
320
+ `community-adapted` credit and shows in its "Adapted by" graph.
259
321
  </example>
260
322
 
261
323
  <example>
262
324
  User: "publish my new skill"
263
325
  1. kordis push ./SKILL.md --dry-run --json
264
- 2. validation_failed → list each flag, fix the file, re-run
326
+ 2. dry_run_failed → list each flag, fix the file, re-run
265
327
  3. clean → kordis push ./SKILL.md --json
266
328
  4. show {full_slug, version, url}
267
329
  </example>
@@ -271,7 +333,8 @@ User: "release v0.2.0 of <full_slug>"
271
333
  - previous version was 0.1.x
272
334
  1. drop or align the frontmatter `version` field — `--bump minor` from
273
335
  0.1.x will yield 0.2.0; if the file says `0.2.0` that matches and is OK,
274
- if it says anything else (e.g. `0.1.0`) the API will reject
336
+ if it says anything else (e.g. `0.1.0`) the real push is refused (the
337
+ dry run does not compare versions)
275
338
  2. kordis push <file> --bump minor --dry-run --json
276
339
  3. clean → kordis push <file> --bump minor --json
277
340
  </example>
@@ -51,6 +51,23 @@ Post a top-level comment on a package. Auth required.
51
51
  - `question` — explicit question expecting a reply (typically from the
52
52
  author). Use when the user wants a response, not just to share thought.
53
53
 
54
+ **What the server refuses.** A comment's title and body get the same content
55
+ screen a published package gets: a download piped into a shell, an encoded
56
+ `eval`, API keys, tokens and private keys, and acceptable-use violations
57
+ (markets for stolen data, malware or attacks for hire, drug and weapon
58
+ brokering, abuse material, targeted harassment). A hit refuses the whole
59
+ comment with `usage_error` (exit 2) and nothing is posted, so there is nothing
60
+ to delete. The message names the field that matched (`comment title rejected …`
61
+ or `comment body rejected …`). Rewrite that field as the hint says and run the
62
+ command again; never resend the same text.
63
+
64
+ - **Reports: describe, do not quote.** A `report` that pastes the offending
65
+ line of a dangerous package (a `curl … | bash`, a key) is refused by the
66
+ same screen. Say what the step does and where it is instead.
67
+ - **Marker lines come back `[redacted]`.** A title or body line that imitates
68
+ the registry's untrusted-content markers is stored as `[redacted]`. Show the
69
+ user the `title` the response returns, not the one you sent.
70
+
54
71
  Replies, edits, status flips (open/resolved on a `report`), listing
55
72
  existing comments, and reading back your own comments are NOT yet exposed
56
73
  in the CLI. Direct the user to `https://kordis.store/<full_slug>` for
@@ -140,6 +157,8 @@ User: "leave a tip on foo/bar saying it pairs well with baz/qux"
140
157
  User: "report that the v0.3.0 release of foo/bar is broken on macOS"
141
158
  - kordis comment foo/bar --kind report --title "v0.3.0 broken on macOS" \
142
159
  --body "<reproducible details>" --json
160
+ - if it is refused (`usage_error`, exit 2), describe the problem without
161
+ quoting the offending text and post again
143
162
  - mention that opening/resolving the report later requires the web UI
144
163
  </example>
145
164
 
@@ -56,7 +56,13 @@ Returns the full body (Markdown) of a text-bearing package, plus an
56
56
  `attribution` string. `--pin <semver>` overrides any `@suffix` on the slug.
57
57
  **Context economy**: `--outline` returns just the section map (heading names);
58
58
  then `--section <name>` returns one section's body. Read the outline first
59
- when a body is large and you only need part of it.
59
+ when a body is large and you only need part of it. A section name the package
60
+ does not have exits 2 (`usage_error`): under `--json` the refusal is the error
61
+ envelope on stdout, and its `hint` lists the section names that do exist (or,
62
+ for a package with none, says to read the whole body); without `--json` it goes
63
+ to stderr only, so a body redirected into a file never captures it. Either way,
64
+ the `kordis read` command it prints keeps the version, `--pin` and
65
+ `--allow-pending` you read with, so it reaches the same body.
60
66
 
61
67
  **Untrusted-content fence (all output modes, incl. `--json`)**: the body (and
62
68
  outline description / section slice) arrives wrapped between exactly two lines
@@ -191,18 +191,72 @@ summary before you answer from it — never quote it back on the label alone.
191
191
  ## Two answers that are answers
192
192
 
193
193
  **Nothing matched.** Say so, and do not go read their folder yourself to
194
- compensate. At the default depth this has already searched every note body of
195
- **the brain it searched**, so "nothing" is real information about that brain
196
- rather than a hint to search harder — read `semantic` first, per above. Broader
197
- words are still worth one retry; `--depth exact` is not — it looks at strictly
198
- less.
194
+ compensate. At the default depth this has already searched the body of every
195
+ note recall could read in **the brain it searched**, so "nothing" is real
196
+ information about those notes rather than a hint to search harder — read
197
+ `semantic` first, per above. Broader words are still worth one retry;
198
+ `--depth exact` is not — it looks at strictly less.
199
+
200
+ In a brain in a folder, that is not a promise about every file. Notes it left
201
+ out are counted below, but recall never looks inside a folder the user's own
202
+ rules exclude (`.gitignore`, `.memgignore`, the rules file): notes in one are
203
+ neither searched nor counted, and an answer where other notes were searched does
204
+ not mention them. So an empty answer is not proof that the user never wrote
205
+ something down. Say what was searched, and that folders their rules exclude
206
+ were not, rather than telling them they never wrote it. Do not open such a
207
+ folder yourself.
199
208
 
200
209
  One brain is not the account. On a hosted account with more than one brain, an
201
- empty answer now names the brains that were NOT read and prints a runnable
202
- `--brain` command for each. Run it before you tell them the fact was never
203
- written down — four testers reached that conclusion about a note sitting in the
204
- next brain, and three of them said it out loud. Reading another brain does not
205
- move the machine's pointer.
210
+ empty answer — or one of template cards only — names the brains that were NOT
211
+ read and prints a runnable `--brain` command for each. Run it before you tell
212
+ them the fact was never written down — four testers reached that conclusion
213
+ about a note sitting in the next brain, and three of them said it out loud.
214
+ Reading another brain does not move the machine's pointer.
215
+
216
+ With `--json`, an answer with nothing the user wrote in it carries `hint`: no
217
+ cards, or only template cards (`scaffolding: true`, below) — those are what
218
+ `init` wrote, not the user's notes. `hint` is the one next step, the most
219
+ specific that applies. First, the brain had nothing to search: it has no notes
220
+ yet (the `remember` command that writes the first one into that brain — or,
221
+ after `--brain` named a brain this machine does not point at, the
222
+ `memgineering use --brain "<name>"` that makes it the target, since `remember`
223
+ has no `--brain`), or it is a folder whose every note was left out (how many,
224
+ why, and the `memgineering reindex` command that names each one). Then: its
225
+ notes are in a script the question is not (ask again in that language), another
226
+ brain was not read (the `--brain` or `--vault` command that reads it), meaning
227
+ was not searched (and the server's reason), the hosted server's own advice, or
228
+ fewer and broader words. Read it before you conclude anything from
229
+ `cards: []` or from template cards. A `memgineering use --brain` in a hint
230
+ moves this machine's pointer for every later command, not just the next one:
231
+ offer it to the user and run it only on their yes. `hint` is absent whenever a
232
+ card they wrote came back.
233
+
234
+ **Held-back notes are counted in both modes.** When an answer has nothing the
235
+ user wrote in it, recall counts what a brain in a folder left out, says why,
236
+ and prints the `memgineering reindex` command that names each one: in the
237
+ terminal right under `_Nothing matched._` or under the template cards' header,
238
+ and at the front of `hint`. It opens with "Nothing in this brain could be
239
+ searched: …" when no note could be searched at all, and with "Not every note in
240
+ this brain was searched: …" when other notes were searched and at least one
241
+ note of the first kind below was left out. Recall itself never names or shows a
242
+ left-out note. The counts come in two kinds, and they mean different things:
243
+
244
+ - **Notes**: excluded by the user's rules (`.gitignore`, `.memgignore`, the
245
+ rules file), listed in `.memgdeny`, held back because the name looks private,
246
+ refused because the content looks like a credential, or behind a symlink. A
247
+ note the user wrote exists and was left out on purpose. Do NOT tell them the
248
+ brain is empty or that they never wrote it down: tell them such a note exists,
249
+ and why; to say where it is, run that `reindex` — it lists each path and
250
+ changes no note — and give them the path. Do not open, print or read a
251
+ held-back note into the conversation unless they ask what it says: it was
252
+ held back because it may be private.
253
+ - **Places nobody looked inside**: an excluded folder ("not looked inside"), or
254
+ another symlink, which is never followed. These are not evidence of a note —
255
+ an ignored `images/` or `.venv/` holds none. Say that recall could not look
256
+ inside them and that `reindex` names them; do not say a note was held back.
257
+ When "Nothing in this brain could be searched" lists only these, the brain may
258
+ hold no notes at all. Beside notes that were searched they are not counted at
259
+ all — see **Nothing matched** above.
206
260
 
207
261
  **Cards that are not answers.** A card marked `not filled in yet` is one of the
208
262
  five `01_BASE/` files, still exactly what `init` wrote (line endings aside). Nobody has answered
@@ -506,17 +506,23 @@ by hand in the ordinary case — writes keep the index in step on their own.
506
506
  what is missing and clears stored rows for notes the brain has since been told
507
507
  not to read, reporting both; `--local` forces the folder brain. On a brain in a
508
508
  FOLDER it also names what it left out — notes a deny name, a `.memgignore` rule
509
- or a symlink kept from the index, and any folder a rule excluded whole — so "3
510
- notes rebuilt" is never the whole answer when a fourth was dropped. A hosted
511
- reindex cannot say that: the rules are applied on the machine that pushed, so
512
- the server only ever saw what arrived.
509
+ or a symlink kept from the index, any folder a rule excluded whole, and any other
510
+ symlink, which is never followed — so "3 notes rebuilt" is never the whole
511
+ answer when a fourth was dropped. A hosted reindex cannot say that: the rules are
512
+ applied on the machine that pushed, so the server only ever saw what arrived.
513
513
 
514
514
  Under `--json` that is `not_indexed` with `denied_by_name`, `denied_explicitly`,
515
- `excluded_by_rule`, `skipped_symlinks` and `excluded_folders`. **Read the count
516
- together with `excluded_folders`**: a rule that names a folder stops the walk at
517
- the folder, so nothing inside it is ever counted — `not_indexed: 0` beside a
518
- non-empty `excluded_folders` means the count is a floor, not the answer. `push`
519
- reports the same set with its total under `not_sent`. `--rebuild`
515
+ `excluded_by_rule`, `skipped_symlinks`, `excluded_folders` and
516
+ `skipped_other_symlinks`. **Read the count together with the last two**: a rule
517
+ that names a folder stops the walk at the folder, and a symlink is never
518
+ followed, so the notes in such a folder are never counted — `not_indexed: 0`
519
+ beside a non-empty `excluded_folders` or `skipped_other_symlinks` means the count
520
+ is a floor, not the answer. `push` reports the same set with its total under
521
+ `not_sent`. A `recall` that brings back nothing the user wrote — no cards, or
522
+ only templates — counts the same set by reason in both modes (beside notes it
523
+ did search, only when a note is among them) and prints this command, never a
524
+ path; what to tell the user then is in `memgineering-recall`.
525
+ `--rebuild`
520
526
  exists for one situation only, where an embedding model changed the shape of its
521
527
  answers without changing its name and the index has to be laid down again.
522
528
 
package/dist/index.js CHANGED
@@ -134,12 +134,32 @@ var init_enums = __esm({
134
134
  }
135
135
  });
136
136
 
137
+ // ../../packages/contracts/src/refresh-race.ts
138
+ function isRefreshRace(body) {
139
+ if (typeof body !== "object" || body === null) return false;
140
+ const error = body.error;
141
+ if (typeof error !== "object" || error === null) return false;
142
+ const { code, flags } = error;
143
+ if (code !== "not_authenticated" || !Array.isArray(flags)) return false;
144
+ return flags.some(
145
+ (flag2) => typeof flag2 === "object" && flag2 !== null && flag2.code === REFRESH_RACE_FLAG
146
+ );
147
+ }
148
+ var REFRESH_RACE_FLAG;
149
+ var init_refresh_race = __esm({
150
+ "../../packages/contracts/src/refresh-race.ts"() {
151
+ "use strict";
152
+ REFRESH_RACE_FLAG = "refresh_rotated_by_another_request";
153
+ }
154
+ });
155
+
137
156
  // ../../packages/contracts/src/errors.ts
138
157
  import { z as z3 } from "zod";
139
158
  var ErrorCodeSchema, ERROR_EXIT_CODES, ReviewFlagSchema, ApiErrorSchema;
140
159
  var init_errors = __esm({
141
160
  "../../packages/contracts/src/errors.ts"() {
142
161
  "use strict";
162
+ init_refresh_race();
143
163
  ErrorCodeSchema = z3.enum([
144
164
  "generic_failure",
145
165
  // 1
@@ -1159,21 +1179,33 @@ var init_api = __esm({
1159
1179
  */
1160
1180
  content: z11.string().min(50).max(1e5),
1161
1181
  /**
1162
- * Accepted and ignored. core-api stores the title, description and type it
1163
- * reads from the frontmatter of `content`, which must carry all three; these
1164
- * three fields change nothing (`kordis push --type` sends `type` here).
1182
+ * Optional, and refused when it disagrees. core-api stores the title,
1183
+ * description and type it reads from the frontmatter of `content`, which must
1184
+ * carry all three (the title may come from `name:`). A value here that
1185
+ * differs from the frontmatter's, compared trimmed, is refused as
1186
+ * `usage_error` (HTTP 400) with a hint naming the frontmatter key; absent
1187
+ * means no claim. A `type` the registry cannot publish at all (`scoped-rule`,
1188
+ * `mdc-rule`, `plugin`) names no key to write: its hint says the type cannot
1189
+ * be published yet and to drop `--type`. `kordis push` ≤0.15.2 sends
1190
+ * `--type` here, and writes `type: skill` into the frontmatter of a SKILL.md
1191
+ * or *.skill.md file, a folder or a .skill bundle that names no type — so
1192
+ * `--type hook` on one is refused, where until 2026-10 it published a skill.
1193
+ * Later versions send no `type`: they write `--type` into the frontmatter.
1165
1194
  */
1166
1195
  title: z11.string().min(1).max(200).optional(),
1167
1196
  description: z11.string().min(1).max(2048).optional(),
1168
1197
  type: HarnessTypeSchema.optional(),
1169
1198
  /**
1170
1199
  * A new package is published under this slug instead of the one derived from
1171
- * the frontmatter title. An update keeps the slug of the package it updates.
1200
+ * the frontmatter title. An update keeps the slug of the package it updates:
1201
+ * a slug here that differs from the target's is refused (`usage_error`).
1172
1202
  */
1173
1203
  slug: SlugSchema.optional(),
1174
1204
  /**
1175
- * When updating, the target full_slug. Omitted for new packages (server
1176
- * derives slug from title or accepts inline `slug`).
1205
+ * When updating, the target full_slug; its presence is what makes a publish
1206
+ * an update (`PUT /api/packages/:u/:s` sets it from the path). Omitted for a
1207
+ * new package, whose slug is `slug` or else derived from the frontmatter
1208
+ * title — never derived on an update.
1177
1209
  */
1178
1210
  target_full_slug: FullSlugSchema.optional(),
1179
1211
  /**
@@ -1181,6 +1213,12 @@ var init_api = __esm({
1181
1213
  */
1182
1214
  bump: z11.enum(["patch", "minor", "major"]).optional(),
1183
1215
  changelog: z11.string().max(2e3).optional(),
1216
+ /**
1217
+ * Refused when non-empty (`usage_error`): a package's dependencies are read
1218
+ * from its frontmatter's `depends:` and `recommends:` lists only. Kept in the
1219
+ * schema so the server sees them to refuse them — this object is non-strict,
1220
+ * and an unlisted key would be stripped without a word.
1221
+ */
1184
1222
  depends: z11.array(z11.string()).optional(),
1185
1223
  recommends: z11.array(z11.string()).optional(),
1186
1224
  /**
@@ -1680,9 +1718,122 @@ var init_telemetry = __esm({
1680
1718
  });
1681
1719
 
1682
1720
  // ../../packages/contracts/src/review-patterns.ts
1721
+ var STAGE_1_REJECT_PATTERNS, STAGE_1_5_AUP_PATTERNS, DETERMINISTIC_REJECT_PATTERNS;
1683
1722
  var init_review_patterns = __esm({
1684
1723
  "../../packages/contracts/src/review-patterns.ts"() {
1685
1724
  "use strict";
1725
+ STAGE_1_REJECT_PATTERNS = [
1726
+ {
1727
+ code: "curl_pipe_shell",
1728
+ pattern: /\bcurl\s+[^|;]+\s*\|\s*(?:(?:sudo|doas)\s+)?(?:-E\s+|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:sh|bash|zsh|ksh|dash|csh|tcsh|fish)\b/i,
1729
+ hint: "Replace `curl \u2026 | bash` with a verifiable install path: download the script to a file, show the user `cat` of it for inspection, then `bash <file>` only after confirmation."
1730
+ },
1731
+ {
1732
+ code: "wget_pipe_shell",
1733
+ pattern: /\bwget\s+[^|;]+\s*\|\s*(?:(?:sudo|doas)\s+)?(?:-E\s+|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:sh|bash|zsh|ksh|dash|csh|tcsh|fish)\b/i,
1734
+ hint: "Replace `wget \u2026 | bash` with a verifiable install path: download the script to a file, show the user `cat` of it for inspection, then `bash <file>` only after confirmation."
1735
+ },
1736
+ {
1737
+ code: "shell_process_substitution",
1738
+ pattern: /(?:bash|sh|zsh)\s*<\(\s*(?:curl|wget)\b/i,
1739
+ hint: "Replace `bash <(curl \u2026)` with a verifiable install path: download the script to a file, show the user `cat` of it for inspection, then `bash <file>` only after confirmation."
1740
+ },
1741
+ {
1742
+ code: "eval_atob",
1743
+ pattern: /\beval\s*\(\s*atob\b/i,
1744
+ hint: "Encoded eval (`eval(atob(...))`) is a hallmark of obfuscated payloads. Move the logic into plain code the reader can audit."
1745
+ },
1746
+ {
1747
+ code: "eval_buffer_from",
1748
+ pattern: /\beval\s*\(\s*Buffer\.from\b/i,
1749
+ hint: "Encoded eval (`eval(Buffer.from(...))`) is a hallmark of obfuscated payloads. Move the logic into plain code the reader can audit."
1750
+ },
1751
+ {
1752
+ code: "aws_access_key",
1753
+ pattern: /\b(?:AKIA|ASIA|AGPA)[0-9A-Z]{16}\b/,
1754
+ hint: "Remove the AWS access key from the body. Reference credentials via env var (`AWS_ACCESS_KEY_ID`); rotate the leaked key immediately if it was real."
1755
+ },
1756
+ {
1757
+ code: "private_key_block",
1758
+ pattern: /-----BEGIN\s+(?:[A-Z0-9]+\s+)?PRIVATE\s+KEY(?:\s+BLOCK)?-----/,
1759
+ hint: "Remove the private key block from the body. Reference keys via env var or a secret manager; rotate the leaked key immediately if it was real."
1760
+ },
1761
+ {
1762
+ code: "google_api_key",
1763
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/,
1764
+ hint: "Remove the Google API key from the body. Reference credentials via env var; rotate the leaked key immediately if it was real."
1765
+ },
1766
+ {
1767
+ code: "slack_token",
1768
+ pattern: /\b(?:xox[abpors]|xapp|xoxe)-[0-9a-zA-Z-]{10,}\b/,
1769
+ hint: "Remove the Slack token from the body. Reference tokens via env var; revoke the leaked token immediately if it was real."
1770
+ },
1771
+ {
1772
+ code: "github_token",
1773
+ pattern: /\b(?:gh[pousr]_[A-Za-z0-9_]{36,}|github_pat_[A-Za-z0-9_]{60,})\b/,
1774
+ hint: "Remove the GitHub token from the body. Reference tokens via env var; revoke the leaked token immediately if it was real."
1775
+ }
1776
+ ];
1777
+ STAGE_1_5_AUP_PATTERNS = [
1778
+ {
1779
+ // Stolen-card / bank-credential trafficking storefronts. Anchored to the
1780
+ // trafficking jargon that only appears in carding markets, not in the
1781
+ // defensive "how to detect carding" literature. `dumps` is also an
1782
+ // ordinary developer verb — "this hook dumps vendor files", "dumps store
1783
+ // state", "dumps market data to CSV" all matched the shared noun list —
1784
+ // so after `dumps` only sale framing (`shop`, `for sale`) counts, while
1785
+ // `cvv` and `fullz` keep the full list. Narrowed when comments started
1786
+ // running this table (2026-10-14).
1787
+ code: "aup_stolen_financial_data_market",
1788
+ pattern: /\b(?:cvv|fullz)\s+(?:shop|store|for\s+sale|market|vendor)\b|\bdumps\s+(?:shop|for\s+sale)\b|\bdumps?\s+with\s+pin\b|\bsell(?:ing)?\s+(?:cvv|fullz|dumps)\b/i,
1789
+ hint: "This reads as a storefront for trafficking stolen financial data, which violates the Kordis AUP. If the package is about DETECTING or DEFENDING against card fraud, reword it so it does not read as an offer to buy/sell stolen data."
1790
+ },
1791
+ {
1792
+ // DDoS-for-hire (booter/stresser) services sold on a subscription.
1793
+ code: "aup_ddos_for_hire",
1794
+ pattern: /\b(?:booter|stresser)\s+(?:service|panel|subscription)\b|\b(?:ddos|dstat)\s+for\s+hire\b|\bhire\s+(?:a\s+)?(?:booter|stresser|ddos)\b/i,
1795
+ hint: "This reads as a DDoS-for-hire (booter/stresser) service, which violates the Kordis AUP. Load-testing tooling is fine \u2014 describe it as testing infrastructure you own/are authorized to test, not a for-hire attack service."
1796
+ },
1797
+ {
1798
+ // Malware/stealer/ransomware sold or distributed as a product ("builder
1799
+ // for sale", "buy our stealer"). A skill that DEFENDS against these, or a
1800
+ // malware-analysis skill, does not use buy/sell framing.
1801
+ code: "aup_malware_sale",
1802
+ pattern: /\b(?:ransomware|stealer|infostealer|rat|keylogger|botnet)\s+(?:builder|kit|panel|source)\s+(?:for\s+sale|for\s+rent)\b|\b(?:buy|sell(?:ing)?|rent)\s+(?:our\s+)?(?:ransomware|stealer|infostealer|botnet)\b/i,
1803
+ hint: "This reads as an offer to sell/rent malware, which violates the Kordis AUP. Malware ANALYSIS and DEFENSE content is welcome \u2014 reword so it does not read as distributing a weaponized build."
1804
+ },
1805
+ {
1806
+ // Child sexual abuse material. Unlike the rest of this table this entry is
1807
+ // NOT anchored to solicitation: it matches the spelled-out phrases ("child
1808
+ // porn", "child pornography", "child sexual abuse material") wherever they
1809
+ // appear, child-safety and moderation education included. Only the
1810
+ // acronym "CSAM" on its own does not match ("how platforms detect CSAM
1811
+ // for NCMEC reporting" passes). Kept this broad on purpose (2026-10-14):
1812
+ // comments have no Stage-2 review after this table, so a miss costs more
1813
+ // than a false positive.
1814
+ code: "aup_csam",
1815
+ pattern: /\bchild\s+(?:porn(?:ography)?|sexual\s+abuse\s+material)\b/i,
1816
+ hint: "This reads as solicitation or distribution of child sexual abuse material, which is illegal and violates the Kordis AUP. This is a terminal, non-appealable rejection."
1817
+ },
1818
+ {
1819
+ // Drug / untraceable-weapon brokering with explicit no-id / untraceable /
1820
+ // dark-web fulfillment language — not a bare mention of a substance or
1821
+ // firearm topic (e.g. harm-reduction or policy content does not use this
1822
+ // buy/sell + untraceable-fulfillment framing together).
1823
+ code: "aup_drug_weapon_brokering",
1824
+ pattern: /\b(?:buy|sell(?:ing)?)\b[\s\S]{0,40}\b(?:cocaine|heroin|fentanyl|methamphetamine|untraceable firearms?|untraceable guns?)\b[\s\S]{0,40}\b(?:no id|untraceable|worldwide shipping|dark ?web)\b/i,
1825
+ hint: "This reads as brokering illegal drugs or untraceable weapons for sale, which violates the Kordis AUP. If the package is about DETECTING or REPORTING such activity, reword it so it does not read as an offer to buy/sell."
1826
+ },
1827
+ {
1828
+ // A directive to dox or swat a specific, named/tagged target — not a
1829
+ // bare mention of doxxing/swatting as a topic (e.g. "how to protect
1830
+ // yourself from doxxing" does not match).
1831
+ code: "aup_targeted_harassment_directive",
1832
+ pattern: /\b(?:dox|swat)\s+(?:this (?:guy|girl|person|user)|him|her|@[\w.]+)\b/i,
1833
+ hint: "This reads as a directive to dox or swat a specific target, which violates the Kordis AUP. Remove the targeted-harassment instruction."
1834
+ }
1835
+ ];
1836
+ DETERMINISTIC_REJECT_PATTERNS = [...STAGE_1_REJECT_PATTERNS, ...STAGE_1_5_AUP_PATTERNS];
1686
1837
  }
1687
1838
  });
1688
1839
 
@@ -7882,13 +8033,18 @@ async function useClient() {
7882
8033
  } catch (err) {
7883
8034
  const code = err.code;
7884
8035
  if (code === "authorization_pending") {
7885
- if (debug) process.stderr.write("[kordis] auto-finalize: authorization_pending (keeping pending)\n");
8036
+ if (debug)
8037
+ process.stderr.write(
8038
+ "[kordis] auto-finalize: authorization_pending (keeping pending)\n"
8039
+ );
7886
8040
  } else {
7887
8041
  await clearPending();
7888
8042
  if (debug) {
7889
8043
  const msg = err instanceof Error ? err.message : String(err);
7890
- process.stderr.write(`[kordis] auto-finalize: poll failed (code=${code ?? "none"}): ${msg}
7891
- `);
8044
+ process.stderr.write(
8045
+ `[kordis] auto-finalize: poll failed (code=${code ?? "none"}): ${msg}
8046
+ `
8047
+ );
7892
8048
  }
7893
8049
  void reportError("auto_finalize_stage1_fail", err);
7894
8050
  }
@@ -7912,7 +8068,8 @@ async function useClient() {
7912
8068
  cfg = { ...cfg, username, sessionId: pollResult.session_id };
7913
8069
  await saveCliConfig(cfg);
7914
8070
  await clearPending();
7915
- if (debug) process.stderr.write(`[kordis] auto-finalize: persisted tokens for ${username}
8071
+ if (debug)
8072
+ process.stderr.write(`[kordis] auto-finalize: persisted tokens for ${username}
7916
8073
  `);
7917
8074
  } catch (err) {
7918
8075
  await clearPending();
@@ -7941,31 +8098,51 @@ async function useClient() {
7941
8098
  }
7942
8099
  } : {}
7943
8100
  });
8101
+ const renewTokens = async (user, sent) => {
8102
+ try {
8103
+ const refreshed = await api.refreshSession(sent);
8104
+ currentTokens = {
8105
+ access_token: refreshed.access_token,
8106
+ refresh_token: refreshed.refresh_token,
8107
+ session_id: refreshed.session_id,
8108
+ expires_at_epoch: Math.floor(Date.now() / 1e3) + refreshed.expires_in
8109
+ };
8110
+ await keychain.save(user, currentTokens);
8111
+ await saveCliConfig({ ...cfg, sessionId: refreshed.session_id });
8112
+ return true;
8113
+ } catch (refreshErr) {
8114
+ if (!lostRefreshRace(refreshErr)) return false;
8115
+ const stored = await storedPairOtherThan(user, sent);
8116
+ if (stored === null) return false;
8117
+ currentTokens = stored;
8118
+ return true;
8119
+ }
8120
+ };
7944
8121
  const authedRequest = async (fn) => {
7945
8122
  try {
7946
8123
  return await fn(api);
7947
8124
  } catch (err) {
7948
- if (err instanceof KordisApiError && err.code === "not_authenticated" && currentTokens && username) {
7949
- try {
7950
- const refreshed = await api.refreshSession(currentTokens.refresh_token);
7951
- currentTokens = {
7952
- access_token: refreshed.access_token,
7953
- refresh_token: refreshed.refresh_token,
7954
- session_id: refreshed.session_id,
7955
- expires_at_epoch: Math.floor(Date.now() / 1e3) + refreshed.expires_in
7956
- };
7957
- await keychain.save(username, currentTokens);
7958
- await saveCliConfig({ ...cfg, sessionId: refreshed.session_id });
7959
- return await fn(api);
7960
- } catch {
7961
- throw err;
7962
- }
7963
- }
7964
- throw err;
8125
+ const renewed = err instanceof KordisApiError && err.code === "not_authenticated" && currentTokens !== null && username !== null && await renewTokens(username, currentTokens.refresh_token);
8126
+ if (!renewed) throw err;
7965
8127
  }
8128
+ return fn(api);
7966
8129
  };
7967
8130
  return { api, keychain, username, apiUrl: cfg.apiUrl, authedRequest };
7968
8131
  }
8132
+ function lostRefreshRace(err) {
8133
+ return err instanceof KordisApiError && err.status === 401 && isRefreshRace({ error: { code: err.code, flags: err.flags } });
8134
+ }
8135
+ var RACE_REREADS_AT_MS = [500, 1e3, 2e3];
8136
+ async function storedPairOtherThan(username, sent) {
8137
+ let waited = 0;
8138
+ for (const at of RACE_REREADS_AT_MS) {
8139
+ await new Promise((resolve6) => setTimeout(resolve6, at - waited));
8140
+ waited = at;
8141
+ const stored = await createKeychain().then((store) => store.load(username)).catch(() => null);
8142
+ if (stored !== null && stored.refresh_token !== sent) return stored;
8143
+ }
8144
+ return null;
8145
+ }
7969
8146
 
7970
8147
  // src/ui.ts
7971
8148
  import kleur from "kleur";
@@ -9287,6 +9464,7 @@ function lintForLeaks(sources) {
9287
9464
  }
9288
9465
 
9289
9466
  // src/commands/push.ts
9467
+ init_src();
9290
9468
  var collect = (val, acc) => acc.concat(val);
9291
9469
  function toLineage(raws, kind) {
9292
9470
  return (raws ?? []).map((raw) => {
@@ -9301,7 +9479,10 @@ function toLineage(raws, kind) {
9301
9479
  var ASSET_MAX_BYTES = 2 * 1024 * 1024;
9302
9480
  var BUNDLE_MAX_BYTES = 5 * 1024 * 1024;
9303
9481
  function pushCommand() {
9304
- return new Command4("push").description("publish a new package or a new version").argument("<path>", "SKILL.md file, .skill ZIP, or skill directory").option("--bump <strategy>", "patch|minor|major (update only)", "patch").option("--slug <slug>", 'override slug; accepts "user/slug" to point at a specific package').option("--type <type>", "override type inference").option("--changelog <text>", "optional changelog entry").option("--dry-run", "validate only; do not publish").option("--no-wait", "return immediately without waiting for review").option(
9482
+ return new Command4("push").description("publish a new package or a new version").argument("<path>", "SKILL.md file, .skill ZIP, or skill directory").option("--bump <strategy>", "patch|minor|major (update only)", "patch").option("--slug <slug>", 'override slug; accepts "user/slug" to point at a specific package').option(
9483
+ "--type <type>",
9484
+ "type to write into a frontmatter that names none (else a SKILL.md, folder or .skill is a skill); refused if the frontmatter names another"
9485
+ ).option("--changelog <text>", "optional changelog entry").option("--dry-run", "validate only; do not publish").option("--no-wait", "return immediately without waiting for review").option(
9305
9486
  "--adapted-from <slug>",
9306
9487
  "source this package was adapted from (repeatable); records lineage",
9307
9488
  collect,
@@ -9316,17 +9497,15 @@ function pushCommand() {
9316
9497
  let contentLabel = "SKILL.md";
9317
9498
  if (s.isDirectory()) {
9318
9499
  ({ content, assets } = await bundleDirectory(abs));
9319
- content = ensureTypeField(content, "skill");
9500
+ content = resolveType(content, opts.type, "skill");
9320
9501
  } else if (abs.endsWith(".skill")) {
9321
9502
  ({ content, assets } = await bundleSkillZip(abs));
9322
- content = ensureTypeField(content, "skill");
9503
+ content = resolveType(content, opts.type, "skill");
9323
9504
  } else {
9324
9505
  content = await readFile6(abs, "utf8");
9325
9506
  contentLabel = basename(abs);
9326
9507
  const isSkillFilename = abs.endsWith("/SKILL.md") || abs.endsWith(".skill.md");
9327
- if (isSkillFilename) {
9328
- content = ensureTypeField(content, "skill");
9329
- }
9508
+ content = resolveType(content, opts.type, isSkillFilename ? "skill" : void 0);
9330
9509
  }
9331
9510
  let nameFromFm = null;
9332
9511
  try {
@@ -9406,7 +9585,6 @@ function pushCommand() {
9406
9585
  bump: opts.bump,
9407
9586
  slug: opts.slug && !opts.slug.includes("/") ? opts.slug : void 0,
9408
9587
  target_full_slug: targetFullSlug,
9409
- type: opts.type,
9410
9588
  changelog: opts.changelog,
9411
9589
  lineage: lineage.length > 0 ? lineage : void 0,
9412
9590
  dry_run: !!opts.dryRun
@@ -9542,15 +9720,39 @@ function printLeakWarnings(findings) {
9542
9720
  printHuman(c.yellow(` - ${f.message}`));
9543
9721
  }
9544
9722
  }
9545
- function ensureTypeField(content, defaultType) {
9723
+ function resolveType(content, typeFlag, inferred) {
9724
+ if (typeFlag === void 0) return inferred ? ensureTypeField(content, inferred) : content;
9725
+ const declared = frontmatterType(content);
9726
+ if (declared === void 0) return ensureTypeField(content, typeFlag);
9727
+ if (declared !== typeFlag) {
9728
+ throw new KordisApiError(400, {
9729
+ error: {
9730
+ code: "usage_error",
9731
+ message: `--type "${typeFlag}" disagrees with the type in the frontmatter, "${declared}"`,
9732
+ hint: `This file's frontmatter says \`type: ${declared}\`; change it or drop \`--type\`.`
9733
+ }
9734
+ });
9735
+ }
9736
+ return content;
9737
+ }
9738
+ function frontmatterType(content) {
9739
+ try {
9740
+ const value = parseFrontmatter(content).frontmatter?.["type"];
9741
+ return typeof value === "string" ? value : void 0;
9742
+ } catch {
9743
+ return void 0;
9744
+ }
9745
+ }
9746
+ function ensureTypeField(content, type) {
9546
9747
  const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
9547
9748
  if (!fmMatch) return content;
9548
9749
  const fmBlock = fmMatch[1];
9549
9750
  if (/^type\s*:/m.test(fmBlock)) return content;
9550
- const injected = fmMatch[0].replace(/^---\r?\n/, `---
9551
- type: ${defaultType}
9751
+ const scalar = HarnessTypeSchema.safeParse(type).success ? type : JSON.stringify(type);
9752
+ const injected = fmMatch[0].replace(/^---\r?\n/, () => `---
9753
+ type: ${scalar}
9552
9754
  `);
9553
- return content.replace(fmMatch[0], injected);
9755
+ return content.replace(fmMatch[0], () => injected);
9554
9756
  }
9555
9757
  var EXCLUDE_NAMES = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", ".gitkeep"]);
9556
9758
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["__pycache__", "node_modules", ".git", "evals", "dist", ".turbo"]);
@@ -10226,45 +10428,31 @@ function getTranslator(type, format) {
10226
10428
  return fn;
10227
10429
  }
10228
10430
 
10229
- // src/lockfile.ts
10230
- init_brand();
10231
- import { join as join9 } from "path";
10232
- import { readFile as readFile7, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
10233
- function kordisHome2() {
10234
- return brandHome();
10235
- }
10236
- function lockfilePath() {
10237
- return join9(kordisHome2(), "installed.json");
10238
- }
10239
- function lockfileDisplayPath() {
10240
- const path2 = lockfilePath();
10241
- const home = process.env["HOME"];
10242
- return home && path2.startsWith(home) ? "~" + path2.slice(home.length) : path2;
10243
- }
10244
- async function readLockfile() {
10245
- try {
10246
- const raw = await readFile7(lockfilePath(), "utf8");
10247
- return JSON.parse(raw);
10248
- } catch {
10249
- return { packages: {} };
10250
- }
10251
- }
10252
- async function writeLockfile(lf) {
10253
- await mkdir5(kordisHome2(), { recursive: true });
10254
- await writeFile5(lockfilePath(), JSON.stringify(lf, null, 2) + "\n", {
10255
- encoding: "utf8",
10256
- mode: 384
10257
- });
10258
- }
10259
- async function recordInstalled(fullSlug, entry) {
10260
- const lf = await readLockfile();
10261
- lf.packages[fullSlug] = entry;
10262
- await writeLockfile(lf);
10263
- }
10264
-
10265
- // src/lib/merge-emitted-file.ts
10431
+ // ../../packages/translators/src/merge-emitted-file.ts
10266
10432
  import tomlLib2 from "@iarna/toml";
10267
10433
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
10434
+ var MergeRefusal = class extends Error {
10435
+ code;
10436
+ reason;
10437
+ constructor(reason) {
10438
+ super(refusalMessage(reason));
10439
+ this.name = "MergeRefusal";
10440
+ this.code = reason.kind === "prototype_key" ? "unsafe_package_config" : "merge_refused";
10441
+ this.reason = reason;
10442
+ }
10443
+ };
10444
+ function refusalMessage(reason) {
10445
+ switch (reason.kind) {
10446
+ case "prototype_key":
10447
+ return `refusing to merge config fragment \u2014 prototype-polluting key "${reason.key}" at ${reason.path}`;
10448
+ case "malformed_file":
10449
+ return `refusing to merge into "${reason.targetPath}" \u2014 existing file is not valid ${reason.format.toUpperCase()}: ${reason.parseError}`;
10450
+ case "non_object_root":
10451
+ return `refusing to merge into "${reason.targetPath}" \u2014 existing ${reason.format.toUpperCase()} root is not an object`;
10452
+ case "dangling_marker":
10453
+ return `refusing to merge \u2014 found a "kordis:${reason.fullSlug}" start marker with no matching end marker`;
10454
+ }
10455
+ }
10268
10456
  function isPlainObject(v) {
10269
10457
  return typeof v === "object" && v !== null && !Array.isArray(v);
10270
10458
  }
@@ -10289,11 +10477,7 @@ function assertNoProtoKeys(value, path2) {
10289
10477
  if (!isPlainObject(value)) return;
10290
10478
  for (const [k, v] of Object.entries(value)) {
10291
10479
  if (PROTO_KEYS.has(k)) {
10292
- const e = new Error(
10293
- `refusing to merge config fragment \u2014 prototype-polluting key "${k}" at ${[...path2, k].join(".")}`
10294
- );
10295
- e.hint = "The package emits a config key named __proto__/constructor/prototype, which no legitimate tool config needs. Report it to the author if you did not publish it.";
10296
- throw e;
10480
+ throw new MergeRefusal({ kind: "prototype_key", key: k, path: [...path2, k].join(".") });
10297
10481
  }
10298
10482
  assertNoProtoKeys(v, [...path2, k]);
10299
10483
  }
@@ -10302,19 +10486,15 @@ function isOwnedServerLeaf(path2) {
10302
10486
  return path2.length === 2 && (path2[0] === "mcpServers" || path2[0] === "mcp_servers");
10303
10487
  }
10304
10488
  function deepMergeFragment(existing, fragment, path2 = []) {
10305
- if (path2.length === 0) assertNoProtoKeys(fragment, []);
10489
+ assertNoProtoKeys(fragment, path2);
10490
+ return mergeAt(existing, fragment, path2);
10491
+ }
10492
+ function mergeAt(existing, fragment, path2) {
10306
10493
  if (isOwnedServerLeaf(path2)) return fragment;
10307
10494
  if (isPlainObject(existing) && isPlainObject(fragment)) {
10308
10495
  const out = { ...existing };
10309
10496
  for (const [k, v] of Object.entries(fragment)) {
10310
- if (PROTO_KEYS.has(k)) {
10311
- const e = new Error(
10312
- `refusing to merge config fragment \u2014 prototype-polluting key "${k}" at ${[...path2, k].join(".")}`
10313
- );
10314
- e.hint = "The package emits a config key named __proto__/constructor/prototype, which no legitimate tool config needs. Report it to the author if you did not publish it.";
10315
- throw e;
10316
- }
10317
- out[k] = deepMergeFragment(existing[k], v, [...path2, k]);
10497
+ out[k] = mergeAt(existing[k], v, [...path2, k]);
10318
10498
  }
10319
10499
  return out;
10320
10500
  }
@@ -10357,18 +10537,15 @@ function mergeStructuredFile(existingRaw, fragmentRaw, format, targetPath) {
10357
10537
  try {
10358
10538
  existing = parseStructured(existingRaw, format);
10359
10539
  } catch (err) {
10360
- const e = new Error(
10361
- `refusing to merge into "${targetPath}" \u2014 existing file is not valid ${format.toUpperCase()}: ${err.message}`
10362
- );
10363
- e.hint = `Fix or remove the malformed ${targetPath}, then re-run \`kordis pull\`/\`kordis update\`. Refusing to overwrite it blind \u2014 it may hold config from other tools/packages.`;
10364
- throw e;
10540
+ throw new MergeRefusal({
10541
+ kind: "malformed_file",
10542
+ targetPath,
10543
+ format,
10544
+ parseError: err.message
10545
+ });
10365
10546
  }
10366
10547
  if (!isPlainObject(existing) && isPlainObject(fragment)) {
10367
- const e = new Error(
10368
- `refusing to merge into "${targetPath}" \u2014 existing ${format.toUpperCase()} root is not an object`
10369
- );
10370
- e.hint = `${targetPath} parses but its top level is not an object, so a config fragment can't be merged in. Move the file aside (or fix its shape), then re-run \`kordis pull\`/\`kordis update\`.`;
10371
- throw e;
10548
+ throw new MergeRefusal({ kind: "non_object_root", targetPath, format });
10372
10549
  }
10373
10550
  const merged = deepMergeFragment(existing, fragment);
10374
10551
  return stringifyStructured(merged, format);
@@ -10390,15 +10567,67 @@ function mergeFencedFile(existingRaw, fullSlug, block) {
10390
10567
  return existingRaw.slice(0, startMatch.index) + trimmedBlock + existingRaw.slice(spanEnd);
10391
10568
  }
10392
10569
  if (startMatch && endIdx === -1) {
10393
- const e = new Error(
10394
- `refusing to merge \u2014 found a "kordis:${fullSlug}" start marker with no matching end marker`
10395
- );
10396
- e.hint = `The file has a dangling <!-- kordis:${fullSlug}:...:start --> marker. Delete that line (or restore its <!-- kordis:${fullSlug}:end --> line), then re-run \`kordis pull\`/\`kordis update\`.`;
10397
- throw e;
10570
+ throw new MergeRefusal({ kind: "dangling_marker", fullSlug });
10398
10571
  }
10399
10572
  return existingRaw.trimEnd() + "\n\n" + trimmedBlock + "\n";
10400
10573
  }
10401
10574
 
10575
+ // src/lockfile.ts
10576
+ init_brand();
10577
+ import { join as join9 } from "path";
10578
+ import { readFile as readFile7, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
10579
+ function kordisHome2() {
10580
+ return brandHome();
10581
+ }
10582
+ function lockfilePath() {
10583
+ return join9(kordisHome2(), "installed.json");
10584
+ }
10585
+ function lockfileDisplayPath() {
10586
+ const path2 = lockfilePath();
10587
+ const home = process.env["HOME"];
10588
+ return home && path2.startsWith(home) ? "~" + path2.slice(home.length) : path2;
10589
+ }
10590
+ async function readLockfile() {
10591
+ try {
10592
+ const raw = await readFile7(lockfilePath(), "utf8");
10593
+ return JSON.parse(raw);
10594
+ } catch {
10595
+ return { packages: {} };
10596
+ }
10597
+ }
10598
+ async function writeLockfile(lf) {
10599
+ await mkdir5(kordisHome2(), { recursive: true });
10600
+ await writeFile5(lockfilePath(), JSON.stringify(lf, null, 2) + "\n", {
10601
+ encoding: "utf8",
10602
+ mode: 384
10603
+ });
10604
+ }
10605
+ async function recordInstalled(fullSlug, entry) {
10606
+ const lf = await readLockfile();
10607
+ lf.packages[fullSlug] = entry;
10608
+ await writeLockfile(lf);
10609
+ }
10610
+
10611
+ // src/lib/merge-refusal.ts
10612
+ function adaptMergeRefusal(err) {
10613
+ if (!(err instanceof MergeRefusal)) return null;
10614
+ const e = new Error(err.message);
10615
+ e.hint = mergeRefusalHint(err.reason);
10616
+ return e;
10617
+ }
10618
+ function mergeRefusalHint(reason) {
10619
+ switch (reason.kind) {
10620
+ case "prototype_key":
10621
+ return "The package emits a config key named __proto__/constructor/prototype, which no legitimate tool config needs. Report it to the author if you did not publish it.";
10622
+ case "malformed_file":
10623
+ return `Fix or remove the malformed ${reason.targetPath}, then re-run \`kordis pull\`/\`kordis update\`. Refusing to overwrite it blind \u2014 it may hold config from other tools/packages.`;
10624
+ case "non_object_root":
10625
+ return `${reason.targetPath} parses but its top level is not an object, so a config fragment can't be merged in. Move the file aside (or fix its shape), then re-run \`kordis pull\`/\`kordis update\`.`;
10626
+ case "dangling_marker":
10627
+ return `The file has a dangling <!-- kordis:${reason.fullSlug}:...:start --> marker. Delete that line (or restore its <!-- kordis:${reason.fullSlug}:end --> line), then re-run \`kordis pull\`/\`kordis update\`.`;
10628
+ }
10629
+ }
10630
+
10402
10631
  // src/commands/pull.ts
10403
10632
  async function requireExecutableApproval(pkg, fullSlug, files, approve2) {
10404
10633
  if (!pkg.capabilities.executable) return;
@@ -10796,7 +11025,12 @@ async function applyEmittedFile(abs, f, fullSlug, notes) {
10796
11025
  if (err.code !== "ENOENT") throw err;
10797
11026
  }
10798
11027
  if (f.format === "json" || f.format === "yaml" || f.format === "toml") {
10799
- const merged2 = mergeStructuredFile(existingRaw, f.content, f.format, abs);
11028
+ let merged2;
11029
+ try {
11030
+ merged2 = mergeStructuredFile(existingRaw, f.content, f.format, abs);
11031
+ } catch (err) {
11032
+ throw adaptMergeRefusal(err) ?? err;
11033
+ }
10800
11034
  if (existingRaw !== null && existingRaw.trim() !== "") {
10801
11035
  notes?.push(
10802
11036
  `merged into ${f.path} \u2014 comments/formatting in the existing file are not preserved`
@@ -10805,11 +11039,17 @@ async function applyEmittedFile(abs, f, fullSlug, notes) {
10805
11039
  await writeFile6(abs, merged2, "utf8");
10806
11040
  return;
10807
11041
  }
10808
- const merged = mergeFencedFile(existingRaw, fullSlug, f.content);
11042
+ let merged;
11043
+ try {
11044
+ merged = mergeFencedFile(existingRaw, fullSlug, f.content);
11045
+ } catch (err) {
11046
+ throw adaptMergeRefusal(err) ?? err;
11047
+ }
10809
11048
  await writeFile6(abs, merged, "utf8");
10810
11049
  }
10811
11050
 
10812
11051
  // src/commands/read.ts
11052
+ init_src2();
10813
11053
  init_sections();
10814
11054
  import { Command as Command6 } from "commander";
10815
11055
 
@@ -10829,6 +11069,15 @@ function untrustedFenceNotice(sentinel) {
10829
11069
  }
10830
11070
 
10831
11071
  // src/commands/read.ts
11072
+ var shellWord = (word) => /^[\w./@:+-]+$/.test(word) ? word : JSON.stringify(word);
11073
+ function readAgainCommand(fullSlug, parsed, opts) {
11074
+ const suffix = parsed.version ? `@${parsed.version}` : parsed.range ? `@${parsed.range}` : "";
11075
+ return [
11076
+ `kordis read ${shellWord(`${fullSlug}${suffix}`)}`,
11077
+ ...opts.pin === void 0 ? [] : [`--pin ${shellWord(opts.pin)}`],
11078
+ ...opts.allowPending ? ["--allow-pending"] : []
11079
+ ].join(" ");
11080
+ }
10832
11081
  function readCommand() {
10833
11082
  return new Command6("read").description("fetch a package body into the agent context (no install)").argument("<full_slug>", "package identifier {username}/{slug} or {username}/{slug}@{version}").option("--allow-pending", "allow versions whose AI review has not passed", false).option("--pin <semver>", "pin to an exact version (overrides @suffix)").option("--outline", "print only the section map (skeleton) \u2014 cheap on context", false).option("--section <name>", "print only one section (see names with --outline)").action(
10834
11083
  async (rawSlug, opts) => {
@@ -10906,9 +11155,19 @@ Bundled files \u2014 fetch with ${c.cyan(`kordis pull ${res.full_slug}`)}:
10906
11155
  const sec = sectionOf(res.body, opts.section);
10907
11156
  if (!sec) {
10908
11157
  const names = outlineOf(res.body).sections.map((s) => s.name).join(", ");
11158
+ const readAgain = readAgainCommand(res.full_slug, parsed, opts);
11159
+ if (isJsonMode()) {
11160
+ throw new KordisApiError(400, {
11161
+ error: {
11162
+ code: "usage_error",
11163
+ message: `section "${opts.section}" not found`,
11164
+ hint: names ? `Available sections: ${names}. Read one with \`${readAgain} --section <name>\`.` : `This package has no sub-sections \u2014 read the whole body with \`${readAgain}\`.`
11165
+ }
11166
+ });
11167
+ }
10909
11168
  process.stderr.write(
10910
11169
  `${c.red("\u2717")} section "${opts.section}" not found. Available: ${names || "(none \u2014 this package has no sub-sections)"}
10911
- See the map with ${c.cyan(`kordis read ${res.full_slug} --outline`)}.
11170
+ See the map with ${c.cyan(`${readAgain} --outline`)}.
10912
11171
  `
10913
11172
  );
10914
11173
  process.exitCode = 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rayul/kordis",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "description": "Command-line client for the Kordis registry — discover, install, and publish agentic-coding packages.",
6
6
  "license": "Apache-2.0",