omakit 0.6.2 → 0.6.4

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
@@ -6,7 +6,26 @@
6
6
 
7
7
  The three parts are the Run and Store blocks a plugin copies into its own tree, the marketplace's own checks run locally, and repository suites run in a disposable guest. Behind the first are M13's 1,001 security blocker comments from one measured week: 587 raise a Run line, 527 a Store line, and runner plus store handle 777 of 1,001 when read as an upper bound, not as a result for any plugin ([M13](docs/MEASUREMENTS.md#m13-what-the-review-blocks-on-over-one-week-of-comments-and-which-of-it-a-block-can-own)). No reviewer has seen a ported plugin yet.
8
8
 
9
- [![Built for Omarchy: App](https://raw.githubusercontent.com/tcballard/omarchy-badges/75975e5b5bf75e7ede3764bcd2950046f7abfe2c/badges/v1/omarchy-app.svg)](https://github.com/tcballard/omarchy-badges) [![npm version](https://img.shields.io/npm/v/omakit)](https://www.npmjs.com/package/omakit) [![CI status](https://img.shields.io/github/actions/workflow/status/mtolhuys/omakit/ci.yml?branch=main)](https://github.com/mtolhuys/omakit/actions/workflows/ci.yml) [![Socket package report](https://socket.dev/api/badge/npm/package/omakit)](https://socket.dev/npm/package/omakit)
9
+ [![Built for Omarchy: App](https://raw.githubusercontent.com/tcballard/omarchy-badges/75975e5b5bf75e7ede3764bcd2950046f7abfe2c/badges/v1/omarchy-app.svg)](https://github.com/tcballard/omarchy-badges) [![npm version](https://img.shields.io/npm/v/omakit)](https://www.npmjs.com/package/omakit) [![CI status](https://img.shields.io/github/actions/workflow/status/mtolhuys/omakit/ci.yml?branch=main)](https://github.com/mtolhuys/omakit/actions/workflows/ci.yml)
10
+
11
+ <!-- TODO(socket-badge): Restore the live Socket package-report badge when its endpoint reliably returns image/svg+xml without a Cloudflare challenge. Last checked 2026-09-19. -->
12
+
13
+ ## What it delivers
14
+
15
+ | Command | What you get | Documentation |
16
+ | --- | --- | --- |
17
+ | `omakit add <block> [<plugin-dir>]` | Copy the Run or Store block into a plugin. The plugin owns the files, and an edited copy is never overwritten. | [Blocks](docs/BLOCKS.md) |
18
+ | `omakit inspect <plugin-dir>` | See what the tree does and what deserves attention, with the file and line for each observation. | [Inspect](docs/INSPECT.md) |
19
+ | `omakit verify <plugin-repo>` | Get the marketplace's security result for your commit, exactly as it would see it. | [Verify](docs/COMMANDS.md#omakit-verify) |
20
+ | `omakit submit <plugin-repo>` | Run every submission check and get the issue title and body ready to paste. Nothing is posted. | [Submit](docs/SUBMIT.md) |
21
+ | `omakit watch ...` | See whether the marketplace-validated commit is still current and what to do when it is not. | [Watch](docs/VALIDATION_WATCH.md) |
22
+ | `omakit audit [<plugin>]` | Find installed plugins running commits the marketplace never validated. | [Audit](docs/AUDIT.md) |
23
+ | `omakit lab <command>` | Set up, inspect, prove in or prune the disposable Omarchy guest. | [Lab](docs/LAB.md) |
24
+ | `omakit weigh <plugin>` | Measure a plugin's CPU and child processes against the shell's own baseline, then restore the shell config. | [Weigh](docs/WEIGH.md) |
25
+ | `omakit setup`, `omakit doctor` | Prepare or check the pin, completion, tools and disposable lab. | [Install](docs/INSTALL.md) |
26
+ | `omakit pin`, `omakit upgrade`, `omakit parity`, `omakit help --agent` | Maintain Omakit or inspect its own contract and parity proof. | [All commands](docs/COMMANDS.md) |
27
+
28
+ The complete syntax, options, exit codes and JSON/output contract are in the [command reference](docs/COMMANDS.md).
10
29
 
11
30
  ## Install
12
31
 
@@ -19,7 +38,7 @@ Omakit needs [Node 22+](package.json) and Git. `omakit setup` prepares the pinne
19
38
 
20
39
  ## Build
21
40
 
22
- `omakit add run <plugin-dir>` and `omakit add store <plugin-dir>` copy versioned process and private-state plumbing into the plugin's `omakit/` directory; the plugin owns those files, and an edited copy is never overwritten. Commit the copied files before checking, because check reads committed `HEAD` unless you pass `--allow-dirty`.
41
+ `omakit add run <plugin-dir>` and `omakit add store <plugin-dir>` copy versioned process and private-state plumbing into the plugin's `omakit/` directory; the plugin owns those files, and an edited copy is never overwritten. Commit the copied files before checking, because check reads committed `HEAD` unless you pass `--allow-dirty`. Why that plumbing is worth taking from somewhere else is [in one page](docs/WHY.md).
23
42
 
24
43
  ![Recorded terminal showing inspect before omakit add run, the copied Run files, and inspect after the fixture is ported](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/add-run.gif)
25
44
 
@@ -65,7 +84,11 @@ After one `omakit lab setup`, `omakit lab prove <suite>` boots a fresh overlay f
65
84
 
66
85
  ## Documentation
67
86
 
68
- - [Blocks](docs/BLOCKS.md) and [their design spike](docs/BLOCKS_SPIKE.md)
87
+ Every page, and which question each one answers: [the documentation
88
+ index](docs/README.md).
89
+
90
+ - [The path from a plugin to a listing](docs/WALKTHROUGH.md) and [when a command stops](docs/FAILURES.md)
91
+ - [Why the blocks exist](docs/WHY.md), [the blocks themselves](docs/BLOCKS.md) and [their design spike](docs/BLOCKS_SPIKE.md)
69
92
  - [Commands](docs/COMMANDS.md), [inspect](docs/INSPECT.md), [submit](docs/SUBMIT.md), [watch](docs/VALIDATION_WATCH.md), [audit](docs/AUDIT.md) and [weigh](docs/WEIGH.md)
70
93
  - [The disposable lab](docs/LAB.md) and [every measurement](docs/MEASUREMENTS.md)
71
94
  - [Installation](docs/INSTALL.md), [media recipes](docs/media/README.md) and [repository rules](AGENTS.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Tested plumbing, the marketplace's own checks, and a disposable Omarchy to test in.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -8,6 +8,9 @@ description: Build the process and state plumbing of an Omarchy Quattro plugin w
8
8
  This is the build job: copy the measured process and private-state plumbing
9
9
  into the plugin before checking, tracking or proving anything around it.
10
10
 
11
+ The argument behind the blocks, for a reader who wants it, is
12
+ `docs/WHY.md` in the omakit repository.
13
+
11
14
  ## Start every process through Run
12
15
 
13
16
  Whenever plugin code starts a program, it goes through the Run block, never
@@ -162,5 +162,41 @@ words, and so should you.
162
162
  Tell the owner the one thing nobody tells them: the marketplace validated one
163
163
  exact commit, and **editing the issue body** is the only action that makes it
164
164
  validate a newer one. Pushing a fix does nothing. Commenting "fixed in `abc123`" does nothing.
165
- Then use `omakit watch <issue-url>` to check it later, and see
166
- `skills/omarchy-plugin-validation-watch/SKILL.md`.
165
+ Then use `omakit watch <issue-url> <path-to-the-plugin-repo>` to check it
166
+ later, and see `skills/omarchy-plugin-validation-watch/SKILL.md`.
167
+
168
+ When the issue has to be edited, whether to retry after a push or because the
169
+ marketplace refused it, follow the protocol below. It is the same protocol
170
+ the validation-watch skill names, under the same name.
171
+
172
+ ## Retry edit protocol
173
+
174
+ A retry edit is never typed. It is the body `omakit submit` renders, and
175
+ `omakit watch` verifies afterwards that the issue says what `origin` says.
176
+ The reason is omacom/omarchy-plugin-marketplace#7787, 2026-09-20: an agent
177
+ following a skill retyped the whole body for a retry, the Repository URL
178
+ came out as `mtolhuijs/omacrunch` where origin says `mtolhuys/omacrunch`,
179
+ the Maintainer notes were wiped, and the marketplace refused the issue as
180
+ `repository-unreachable` 40 seconds after the edit.
181
+
182
+ 1. Re-run `omakit submit` with the same `--category`, `--tags` and `--notes`
183
+ as the original submission, plus `--body-out <file>`. The Repository URL
184
+ in that body comes from `origin`; you never type it.
185
+ 2. Read the current issue body (`gh issue view <url> --json body`). Diff it
186
+ against the new body. The only lines allowed to differ are under
187
+ "### Maintainer notes". Any other difference means you are about to change
188
+ the submission; stop and show the owner the diff.
189
+ 3. With the owner's explicit approval: `gh issue edit <url> --body-file <file>`.
190
+ 4. Run `omakit watch <url> <path-to-the-plugin-repo>`. Expect `current` within
191
+ two minutes. `wrong-repository` or `refused` means step 2 was skipped; fix
192
+ it now, by the same steps.
193
+
194
+ Never retype the body. Never write the Repository URL by hand. Never edit a
195
+ `current` issue to bump it: a fresh validation clears the reviewer's human
196
+ decision and costs a complete re-read of the plugin.
197
+
198
+ `submission.issue-repository-url` is the check that catches the typo before
199
+ the next run: when you have an open submission issue for this plugin and its
200
+ Repository URL is not `origin`, `submit` is refused and the remedy names the
201
+ issue and the URL to put back. Put it back with the protocol above, not by
202
+ editing the field by hand.
@@ -29,11 +29,22 @@ not a measurement of all 464 issues in the queue.
29
29
  ## Run the check
30
30
 
31
31
  ```bash
32
- omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/<number>
32
+ omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/<number> <path-to-the-plugin-repo>
33
33
  omakit watch --all --json
34
34
  omakit watch --list --json
35
35
  ```
36
36
 
37
+ Give the plugin's checkout (or its github.com URL) as the second argument
38
+ whenever you have it; without one, the current directory is the subject
39
+ when it is such a checkout and its `manifest.json` is the plugin the issue
40
+ names. A directory that is another plugin is not compared, and the report
41
+ says so (`plugin.subjectSkipped` in `--json`): pass the plugin then, do not
42
+ read the origin it prints as the one to put in the issue. With a subject,
43
+ the issue's Repository URL is compared with the plugin's `origin`, and the
44
+ verdict `wrong-repository` names a mismatch before anything else is
45
+ compared. `--json` carries it as `plugin.origin` and
46
+ `plugin.repositoryMatches` (`null` when there is no subject, as in `--all`).
47
+
37
48
  If `omakit` is not installed: `npm install --global omakit` (Omarchy ships Node
38
49
  and npm through mise), then `omakit doctor`; if the command is not found after
39
50
  the install, `"$(npm prefix --global)/bin/omakit" setup` prints the one line
@@ -64,9 +75,8 @@ and publish a newer upstream commit", read from the pin.
64
75
  **`stale`.** The validated commit is behind the repository. Tell the owner plainly: the marketplace has not
65
76
  seen the newer commit, and the fix is to **edit the issue body** (any edit
66
77
  re-triggers validation and the baseline against the new commit). Do not advise
67
- them to push again, and do not advise them to comment. If you are asked to do it,
68
- that is an edit to their issue: get their explicit approval and do it yourself,
69
- outside this tool.
78
+ them to push again, and do not advise them to comment. The edit is made by the
79
+ retry edit protocol below, never by retyping the body.
70
80
 
71
81
  **`current`.** The validated commit is the current HEAD. The submission is genuinely waiting
72
82
  on a person, or on a fix the reviewer asked for that has not been made. Read the
@@ -74,10 +84,51 @@ review comments and address the substance. Do not edit the issue to "bump" it: a
74
84
  fresh validation clears the reviewer's human decision and costs him a complete
75
85
  re-read of the plugin.
76
86
 
87
+ **`wrong-repository`.** The issue's Repository URL is not the plugin's
88
+ `origin`, so the marketplace is validating another repository, or none. The
89
+ output prints both URLs. Tell the owner which one the issue says and which one
90
+ origin says, and fix it by the retry edit protocol below: the body `omakit
91
+ submit` renders carries the origin, and nothing else on the issue changes. Do
92
+ not correct the field by hand.
93
+
94
+ **`refused`.** The marketplace's last validation of this issue failed, and
95
+ that refusal is newer than the last baseline marker. The output prints the
96
+ marketplace's own code, reason and action, read from the pinned feedback
97
+ table; an unmatched reason is printed verbatim as `unrecognised`. Fix what
98
+ the code names in the repository first when it is the repository
99
+ (`readme-missing`, `manifest-invalid`), and when it is the issue itself
100
+ (`repository-unreachable`, `submission-fields-invalid`) the fix is the retry
101
+ edit protocol below. Never report `refused` as waiting on the reviewer.
102
+
77
103
  **`unknown`.** There is no validated commit to compare, the last baseline did not
78
104
  complete, or the repository's HEAD could not be read. The output says which. Never
79
105
  report this as `current`.
80
106
 
107
+ ## Retry edit protocol
108
+
109
+ A retry edit is never typed. It is the body `omakit submit` renders, and
110
+ `omakit watch` verifies afterwards that the issue says what `origin` says.
111
+ The reason is omacom/omarchy-plugin-marketplace#7787, 2026-09-20: an agent
112
+ following this skill retyped the whole body for a retry, the Repository URL
113
+ came out as `mtolhuijs/omacrunch` where origin says `mtolhuys/omacrunch`,
114
+ the Maintainer notes were wiped, and the marketplace refused the issue as
115
+ `repository-unreachable` 40 seconds after the edit.
116
+
117
+ 1. Re-run `omakit submit` with the same `--category`, `--tags` and `--notes`
118
+ as the original submission, plus `--body-out <file>`. The Repository URL
119
+ in that body comes from `origin`; you never type it.
120
+ 2. Read the current issue body (`gh issue view <url> --json body`). Diff it
121
+ against the new body. The only lines allowed to differ are under
122
+ "### Maintainer notes". Any other difference means you are about to change
123
+ the submission; stop and show the owner the diff.
124
+ 3. With the owner's explicit approval: `gh issue edit <url> --body-file <file>`.
125
+ 4. Run `omakit watch <url> <path-to-the-plugin-repo>`. Expect `current` within
126
+ two minutes. `wrong-repository` or `refused` means step 2 was skipped; fix
127
+ it now, by the same steps.
128
+
129
+ Never retype the body. Never write the Repository URL by hand. Never edit a
130
+ `current` issue to bump it.
131
+
81
132
  ## Before you conclude that the reviewer is behind
82
133
 
83
134
  Check the assumption. The maintainer's own queue is a median half a day old, and
@@ -1,5 +1,5 @@
1
1
  {
2
- "commit": "2a3b95629518dfbdaa880bf528ce2ce495b27b45",
3
- "recordedAt": "2026-09-19T16:57:20.050Z",
2
+ "commit": "a0f946e1e900fda2b6d9c7004da3b8684958ec1a",
3
+ "recordedAt": "2026-09-21T06:04:50.384Z",
4
4
  "how": "Written by `node tools/blocks/record-commit.mjs` in the release workflow before `npm pack`, so a packaged omakit, which has no Git checkout, still names the commit its block files come from. In a checkout this file is null and `git rev-parse HEAD` is the source; a package with null here was packed without the release step, and `omakit add` refuses to stamp a header it cannot name."
5
5
  }
@@ -19,7 +19,7 @@
19
19
  // `add` is the one command that writes into a plugin tree: the block's own
20
20
  // files under omakit/, never over a modified copy; docs/BLOCKS.md says what.
21
21
 
22
- import { readdirSync, readFileSync } from "node:fs"
22
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs"
23
23
  import { dirname, resolve } from "node:path"
24
24
  import { fileURLToPath } from "node:url"
25
25
  import { ensurePin, requirePin } from "./pin.mjs"
@@ -27,7 +27,7 @@ import { marketplaceBaselineSection } from "./verify.mjs"
27
27
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
28
28
  import { submitPreflight } from "./submit.mjs"
29
29
  import { askChoices, askWatchIssues } from "./ask.mjs"
30
- import { validationWatch, discoverWatchIssues, validationWatchAll } from "./watch.mjs"
30
+ import { validationWatch, discoverWatchIssues, validationWatchAll, resolveWatchSubject } from "./watch.mjs"
31
31
  import { renderSubmit, renderWatch, renderWatchList, renderWatchAll, renderDoctor, renderVerify } from "./report.mjs"
32
32
  import { consequence } from "./preflight.mjs"
33
33
  import { doctor } from "./doctor.mjs"
@@ -83,6 +83,8 @@ const REMEDY = Object.freeze({
83
83
  "interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
84
84
  "refused": "Fix what the report names, then run it again.",
85
85
  "unknown": "Read what could not be compared in the report above; each row says why.",
86
+ "wrong-repository": "Edit the issue and set the Repository URL field to the plugin's origin, printed in the report. Change nothing else.",
87
+ "no-origin": "Give the checkout a github.com origin remote, or pass the repository URL as the subject.",
86
88
  "drift": "Return each plugin to its validated commit with the git checkout printed beside it, or validate the newer commit through the form the report names.",
87
89
  "not-compared": "Run it in the desktop session whose shell runs these plugins: omarchy-plugin-catalog named no readable source directory for them.",
88
90
  "problems": "omakit setup",
@@ -195,6 +197,20 @@ function refuse(args, document, human, error) {
195
197
  return conclude({ command: CONTEXT.command, args, exit: 1, document, human, error: failure({ ...error, table: REMEDY }) })
196
198
  }
197
199
 
200
+ /**
201
+ * `--body-out`: the rendered issue body, byte for byte and nothing else,
202
+ * so a retry edit is the body `submit` renders and never a body retyped.
203
+ * On #7787 (2026-09-20) the retry was typed, and the Repository URL came
204
+ * out as another account. A person passes the file to `gh`'s
205
+ * `issue edit --body-file`; this tool writes a local file and posts
206
+ * nothing. A body that was not rendered (a refusal, a
207
+ * listing) writes no file and says so.
208
+ */
209
+ function writeBodyOut(out, body) {
210
+ writeFileSync(resolve(out), body)
211
+ return resolve(out)
212
+ }
213
+
198
214
  async function cmdSubmit(args) {
199
215
  const target = positionals(args)[0]
200
216
  if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
@@ -242,7 +258,22 @@ async function cmdSubmit(args) {
242
258
  failFrom(error)
243
259
  }
244
260
  spinner.done()
245
- const human = (colour) => renderSubmit(result, { colour })
261
+ const bodyOut = option(args, "--body-out")
262
+ if (bodyOut && result.issue?.body) {
263
+ try {
264
+ result.bodyFile = writeBodyOut(bodyOut, result.issue.body)
265
+ } catch (error) {
266
+ failFrom(Object.assign(error, { message: `--body-out ${resolve(bodyOut)} could not be written: ${error.message}` }))
267
+ }
268
+ } else if (bodyOut) {
269
+ result.bodyFile = null
270
+ }
271
+ const human = (colour) => {
272
+ const text = renderSubmit(result, { colour })
273
+ if (!bodyOut) return text
274
+ const c = styler(colour)
275
+ return `${text.replace(/\n+$/, "")}\n${result.bodyFile ? `${mark("pass", c)}wrote the body to ${withHomeAbbreviated(result.bodyFile)}` : `${mark("skipped", c)}no body was rendered, so --body-out wrote nothing`}\n`
276
+ }
246
277
  // Three outcomes, two exit codes: `ready` and `listed` are both healthy
247
278
  // states, and only a refusal is a 1.
248
279
  if (result.outcome === "refused") {
@@ -254,7 +285,7 @@ async function cmdSubmit(args) {
254
285
  }
255
286
 
256
287
  async function cmdWatch(args) {
257
- const issueUrl = positionals(args)[0]
288
+ const [issueUrl, subjectTarget] = positionals(args)
258
289
  const all = args.includes("--all")
259
290
  const list = args.includes("--list")
260
291
  const user = option(args, "--user")
@@ -265,11 +296,18 @@ async function cmdWatch(args) {
265
296
  if (!issueUrl && !all && !list && !interactive) {
266
297
  fail("usage", "watch needs an issue or a mode: `omakit watch <issue-url>`, `omakit watch --all`, or `omakit watch --list`. A terminal can pick issues with `omakit watch`.", 2)
267
298
  }
299
+ if (subjectTarget !== undefined && !issueUrl) {
300
+ fail("usage", "a subject belongs to one issue: `omakit watch <issue-url> <subject>`.", 2)
301
+ }
268
302
  const spinner = spinnerFor(args)
269
303
  let result
270
304
  try {
271
305
  if (issueUrl) {
272
- result = await validationWatch({ repoRoot: ROOT, issueUrl, onPhase: spinner.phase })
306
+ // The subject is the plugin's own origin: a path or a github.com URL
307
+ // given after the issue, or the current directory when it is a
308
+ // checkout with one. The issue's Repository URL is compared with it.
309
+ const subject = resolveWatchSubject(subjectTarget)
310
+ result = await validationWatch({ repoRoot: ROOT, issueUrl, subject, onPhase: spinner.phase })
273
311
  } else {
274
312
  const discovery = await discoverWatchIssues({ user, onPhase: spinner.phase })
275
313
  if (list) {
@@ -292,13 +330,17 @@ async function cmdWatch(args) {
292
330
  // A comparison that could not be made is a refusal the tool means, exit
293
331
  // 1: the validated commit is not known to be current. Stale is a fact
294
332
  // about the marketplace, not a failure, and exits 0. Measured on
295
- // 2026-09-19: an unknown verdict exited 2, the usage status.
296
- if (result.verdict?.state === "unknown") {
297
- refuse(args, result, human, { code: "unknown", message: result.verdict.summary, remedy: result.verdict.action || REMEDY.unknown })
333
+ // 2026-09-19: an unknown verdict exited 2, the usage status. An issue
334
+ // that names the wrong repository, or one the marketplace refused, is
335
+ // the same kind of refusal: nothing on it is being validated, and the
336
+ // verdict's own action is the remedy.
337
+ if (["unknown", "wrong-repository", "refused"].includes(result.verdict?.state)) {
338
+ refuse(args, result, human, { code: result.verdict.state, message: result.verdict.summary, remedy: result.verdict.action || REMEDY[result.verdict.state] })
298
339
  return
299
340
  }
300
- if (result.summary?.unknown > 0) {
301
- refuse(args, result, human, { code: "unknown", message: `${result.summary.unknown} of ${result.summary.total} comparison${result.summary.total === 1 ? "" : "s"} could not be made` })
341
+ if (result.summary?.unknown > 0 || result.summary?.refused > 0) {
342
+ const counts = [result.summary.unknown > 0 && `${result.summary.unknown} could not be compared`, result.summary.refused > 0 && `${result.summary.refused} refused by the marketplace`].filter(Boolean).join(", ")
343
+ refuse(args, result, human, { code: result.summary.unknown > 0 ? "unknown" : "refused", message: `${counts} of ${result.summary.total} issue${result.summary.total === 1 ? "" : "s"}`, remedy: result.summary.unknown > 0 ? REMEDY.unknown : "Read each refused row: the marketplace's own reason and action are printed beside it." })
302
344
  return
303
345
  }
304
346
  succeed(args, result, human)
@@ -0,0 +1,56 @@
1
+ // M15 reproduction: open submission issues whose Repository URL names an
2
+ // owner other than the issue's author. All remote reads use the existing
3
+ // GET-only client; an issue whose body yields no repository is counted as
4
+ // unreadable, never as a match or a mismatch.
5
+ //
6
+ // The case behind it: omacom/omarchy-plugin-marketplace#7787, 2026-09-20. A
7
+ // retry edit typed by an agent put `mtolhuijs` where origin says `mtolhuys`,
8
+ // and the marketplace refused it as `repository-unreachable` 40 seconds after
9
+ // the edit event. A URL whose owner is not the author is not wrong by itself
10
+ // (organisations, forks and co-maintainers exist), so the count is the size
11
+ // of the population this check has to tell a typo apart from, not a count of
12
+ // typos.
13
+ import { resolve } from "node:path"
14
+ import { pathToFileURL } from "node:url"
15
+ import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
16
+ import { repositoryIssues } from "./github.mjs"
17
+ import { repositoryFor } from "./watch.mjs"
18
+ import { parseGitHubUrl } from "../subject/resolve.mjs"
19
+
20
+ export function ownerCounts(rows) {
21
+ const readable = rows.filter((row) => row.owner !== null)
22
+ const differs = readable.filter((row) => row.ownerDiffers).length
23
+ return { total: rows.length, readable: readable.length, unreadable: rows.length - readable.length,
24
+ ownerDiffers: differs, ownerMatches: readable.length - differs,
25
+ differsShareOfReadable: readable.length ? differs / readable.length : null }
26
+ }
27
+
28
+ export async function measureRepositoryOwner(repoRoot, { discover = repositoryIssues, label = "submission" } = {}) {
29
+ const openedAt = new Date().toISOString()
30
+ const { dir: pinDir } = requirePin(repoRoot)
31
+ const [owner, repository] = new URL(MARKETPLACE_PIN.repository).pathname.slice(1).split("/")
32
+ const subjects = (await discover(owner, repository, undefined, { labels: label }))
33
+ .filter((subject) => subject.state === "open" && !subject.pull_request)
34
+ .sort((a, b) => a.number - b.number)
35
+ const rows = []
36
+ for (const subject of subjects) {
37
+ const author = String(subject.user?.login || "")
38
+ const parsed = await repositoryFor(pinDir, subject)
39
+ const gh = parsed.url ? parseGitHubUrl(parsed.url) : null
40
+ rows.push({ issue: subject.number, issueUrl: `${MARKETPLACE_PIN.repository}/issues/${subject.number}`, author,
41
+ labels: (subject.labels || []).map((entry) => (typeof entry === "string" ? entry : entry?.name)).filter(Boolean),
42
+ repository: gh?.url || null, owner: gh?.owner || null,
43
+ ownerDiffers: gh ? gh.owner.toLowerCase() !== author.toLowerCase() : null,
44
+ reason: gh ? null : parsed.error || "no github.com repository URL in the body" })
45
+ }
46
+ return { measurement: "M15", date: openedAt.slice(0, 10), openedAt, completedAt: new Date().toISOString(),
47
+ marketplace: MARKETPLACE_PIN.repository, marketplacePin: MARKETPLACE_PIN.commit,
48
+ command: "node tools/marketplace/measure-repository-owner.mjs", sample: false,
49
+ population: `All open non-PR marketplace issues labelled ${label} at discovery`,
50
+ method: "Repository URL read from each issue body with the pinned submission parser; its owner compared case-insensitively with the issue author's login; a body without a readable github.com URL is unreadable",
51
+ ...ownerCounts(rows), rows }
52
+ }
53
+
54
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
55
+ process.stdout.write(`${JSON.stringify(await measureRepositoryOwner(resolve(process.cwd())), null, 2)}\n`)
56
+ }
@@ -20,8 +20,8 @@
20
20
  export const ACCEPTED = Object.freeze({
21
21
  setup: Object.freeze({ valued: [], flags: ["--yes", "--completion"], positionals: 0 }),
22
22
  pin: Object.freeze({ valued: [], flags: [], positionals: 0 }),
23
- submit: Object.freeze({ valued: ["--category", "--tags", "--notes", "--suggest-tag", "--name", "--out"], flags: ["--offline", "--allow-dirty", "--json"], positionals: 1 }),
24
- watch: Object.freeze({ valued: ["--out", "--user"], flags: ["--json", "--all", "--list"], positionals: 1 }),
23
+ submit: Object.freeze({ valued: ["--category", "--tags", "--notes", "--suggest-tag", "--name", "--out", "--body-out"], flags: ["--offline", "--allow-dirty", "--json"], positionals: 1 }),
24
+ watch: Object.freeze({ valued: ["--out", "--user"], flags: ["--json", "--all", "--list"], positionals: 2 }),
25
25
  verify: Object.freeze({ valued: ["--out"], flags: ["--allow-dirty", "--json"], positionals: 1 }),
26
26
  help: Object.freeze({ valued: [], flags: ["--agent"], positionals: 0 }),
27
27
  upgrade: Object.freeze({ valued: [], flags: ["--dry-run"], positionals: 0 }),
@@ -37,7 +37,7 @@ export const ACCEPTED = Object.freeze({
37
37
  /** The accepted options of a command in the words a refusal prints: `--runs N, --all, ...`. */
38
38
  export function acceptedWords(name) {
39
39
  const spec = ACCEPTED[name]
40
- const value = (option) => ({ "--out": "FILE", "--runs": "N", "--count": "N", "--offset": "N", "--window": "S", "--settle": "S", "--category": "C", "--tags": "A,B", "--from": "FILE", "--toolchain": "DIR" }[option] || "TEXT")
40
+ const value = (option) => ({ "--out": "FILE", "--body-out": "FILE", "--runs": "N", "--count": "N", "--offset": "N", "--window": "S", "--settle": "S", "--category": "C", "--tags": "A,B", "--from": "FILE", "--toolchain": "DIR" }[option] || "TEXT")
41
41
  return [...spec.valued.map((option) => `${option} ${value(option)}`), ...spec.flags].join(", ")
42
42
  }
43
43
 
@@ -178,9 +178,13 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
178
178
  : `${result.validationCommit.local} is the local commit; the marketplace validates the default-branch HEAD it resolves when the issue is opened.`
179
179
  // A skipped check is said on the READY line itself, so the one line an
180
180
  // agent quotes does not read as "everything passed" when one check never
181
- // ran. The count is the whole `skipped` list; today only --offline skips.
181
+ // ran. The count is the whole `skipped` list; --offline skips, and the
182
+ // open-issue check also skips without a credential or an open issue, and
183
+ // then the reason is on its own row.
184
+ const skippedChecks = (result.checks || []).filter((entry) => entry.verdict === "skipped")
182
185
  const skipped = (result.skipped || []).length
183
- const skippedNote = skipped ? ` ${skipped === 1 ? "1 check" : `${skipped} checks`} skipped (--offline).` : ""
186
+ const offlineOnly = skippedChecks.length > 0 && skippedChecks.every((entry) => /\(--offline\)/.test(entry.detail))
187
+ const skippedNote = skipped ? ` ${skipped === 1 ? "1 check" : `${skipped} checks`} skipped (${offlineOnly ? "--offline" : "each says why"}).` : ""
184
188
  out.push(...verdict("pass", "READY", `every blocking check passed.${skippedNote} ${validation}`, c))
185
189
  out.push("")
186
190
  out.push(...section("issue title", c))
@@ -214,6 +218,14 @@ export function renderWatch(result, { colour = colourEnabled() } = {}) {
214
218
  out.push(...field("state", `${result.read.state}${result.read.labels.length ? `; labels ${result.read.labels.join(", ")}` : ""}`, c))
215
219
  out.push(...field("title", result.read.title, c))
216
220
  out.push(...field("plugin repo", result.plugin.repository || `unreadable: ${result.plugin.repositoryError}`, c, { wrapValue: false }))
221
+ if (result.plugin.origin) {
222
+ const origin = result.plugin.repositoryMatches === false ? c("fail", result.plugin.origin) : result.plugin.origin
223
+ out.push(...field("origin", origin, c, { wrapValue: false }))
224
+ out.push(...continuation(result.plugin.repositoryMatches === false ? "the issue names a different repository" : result.plugin.repositoryMatches ? "the issue names this repository" : "not compared: the issue names no repository", c))
225
+ } else if (result.plugin.subjectSkipped) {
226
+ out.push(...field("origin", "not compared", c))
227
+ out.push(...continuation(result.plugin.subjectSkipped.reason, c))
228
+ }
217
229
  if (result.plugin.form === "verify" || result.plugin.form === "verify-legacy") {
218
230
  out.push(...field("form", "plugin update request, read with the marketplace's verification parser", c))
219
231
  }
@@ -233,8 +245,12 @@ export function renderWatch(result, { colour = colourEnabled() } = {}) {
233
245
  } else if (result.headError) {
234
246
  out.push(...field("current HEAD", `unreadable: ${result.headError.message}`, c))
235
247
  }
248
+ if (result.refusal) {
249
+ out.push(...field("refused", `${result.refusal.code} at ${result.refusal.at || "an unrecorded time"}`, c))
250
+ out.push(...continuation(result.refusal.reason, c))
251
+ }
236
252
  out.push("")
237
- const state = { current: "pass", stale: "fail", unknown: "unknown" }[result.verdict.state] || "unknown"
253
+ const state = { current: "pass", stale: "fail", refused: "fail", "wrong-repository": "fail", unknown: "unknown" }[result.verdict.state] || "unknown"
238
254
  out.push(...verdict(state, `VALIDATION ${result.verdict.state.toUpperCase()}`, result.verdict.summary, c))
239
255
  if (result.verdict.action) {
240
256
  out.push("")
@@ -266,7 +282,7 @@ export function renderWatchList(result, { colour = colourEnabled() } = {}) {
266
282
  /** Compact batch report; exact commits and full discussion remain in JSON. */
267
283
  export function renderWatchAll(result, { colour = colourEnabled() } = {}) {
268
284
  const c = styler(colour)
269
- const out = [...field("account", result.account, c), ...field("issues", `${result.summary.total} checked; ${result.summary.current} current, ${result.summary.stale} stale, ${result.summary.unknown} unknown`, c)]
285
+ const out = [...field("account", result.account, c), ...field("issues", `${result.summary.total} checked; ${result.summary.current} current, ${result.summary.stale} stale, ${result.summary.refused || 0} refused, ${result.summary.unknown} unknown`, c)]
270
286
  if (result.reviewCostSummary) {
271
287
  const cost = result.reviewCostSummary
272
288
  const skipped = cost.skipped.length ? `; ${cost.skipped.length} diff(s) skipped (reasons on issue rows)` : ""
@@ -275,7 +291,7 @@ export function renderWatchAll(result, { colour = colourEnabled() } = {}) {
275
291
  out.push("")
276
292
  for (const row of result.issues) {
277
293
  const state = row.report?.verdict.state || "unknown"
278
- const style = { current: "pass", stale: "fail", unknown: "unknown" }[state]
294
+ const style = { current: "pass", stale: "fail", refused: "fail", "wrong-repository": "fail", unknown: "unknown" }[state] || "unknown"
279
295
  out.push(...verdict(style, state.toUpperCase(), `#${row.issue.number} ${watchIssueTitle(row.report?.read.title || row.issue.title)}`, c))
280
296
  out.push(...field("issue", row.issue.url, c, { wrapValue: false }))
281
297
  if (row.documentationDiff?.docsOnly === null) out.push(...field("diff skipped", watchIssueTitle(row.documentationDiff.reason), c))
@@ -6,6 +6,7 @@ import { requirePin } from "./pin.mjs"
6
6
  import { token, issue, parseIssueUrl, compareCommits } from "./github.mjs"
7
7
  import { discoverWatchIssues, repositoryFor } from "./watch.mjs"
8
8
  import { repositorySlug } from "./registry.mjs"
9
+ import { submissionContract } from "./form.mjs"
9
10
 
10
11
  /** Outcome and update label names come from the pin, rather than a second policy. */
11
12
  export async function reviewPolicy(repoRoot) {
@@ -18,26 +19,52 @@ export async function reviewPolicy(repoRoot) {
18
19
  return { automated: policy.securityBaselineOutcome([], []), manual, updateLabel, reviewLabel: `security-${manual}` }
19
20
  }
20
21
 
21
- /** The same discovery as watch --all; no credential or incomplete reads are not zero issues. */
22
- export async function openIssuesForRepository({ repoRoot, repository, offline = false, github = {} }) {
22
+ /**
23
+ * The same discovery as watch --all; no credential or incomplete reads are
24
+ * not zero issues. An issue is the author's for this plugin when its
25
+ * Repository URL is the repository, or, for a `[Plugin]:` submission whose
26
+ * URL is another repository, when its title carries the manifest's name or
27
+ * its body names the manifest's id: on #7787 (2026-09-20) the retyped URL
28
+ * made the author's own issue invisible to a match on the URL alone, and
29
+ * the one check that could have named the typo had nothing to look at.
30
+ * Each matched issue is returned with `sameRepository`, so a caller can
31
+ * tell the two apart.
32
+ *
33
+ * @returns {{ count: number|null, reason: string, account?: string, issues?: { number: number, url: string, repositoryUrl: string|null, sameRepository: boolean, matchedBy: "repository"|"name"|"id" }[] }}
34
+ */
35
+ export async function openIssuesForRepository({ repoRoot, repository, pluginName = "", pluginId = "", offline = false, github = {} }) {
23
36
  if (offline) return { count: null, reason: "not checked (--offline)" }
24
37
  if (!(github.token || token)()) return { count: null, reason: "not checked: no GitHub credential" }
25
38
  try {
26
39
  const discovery = await discoverWatchIssues({ github })
27
40
  const { dir } = requirePin(repoRoot)
28
- let count = 0
41
+ const { titleTemplate } = await submissionContract({ pinDir: dir })
42
+ const name = String(pluginName || "").trim().toLowerCase()
43
+ const id = String(pluginId || "").trim()
44
+ const matched = []
29
45
  let next = 0
30
46
  async function worker() {
31
47
  while (next < discovery.issues.length) {
32
- const target = parseIssueUrl(discovery.issues[next++].url)
48
+ const entry = discovery.issues[next++]
49
+ const target = parseIssueUrl(entry.url)
33
50
  const subject = await (github.issue || issue)(target.owner, target.repository, target.number)
34
51
  const parsed = await repositoryFor(dir, subject)
35
52
  if (!parsed.url) throw new Error(`repository unknown on issue #${target.number}: ${parsed.error}`)
36
- if (repositorySlug(parsed.url) === repositorySlug(repository)) count += 1
53
+ const row = { number: target.number, url: entry.url, repositoryUrl: parsed.url, sameRepository: repositorySlug(parsed.url) === repositorySlug(repository), matchedBy: null }
54
+ if (row.sameRepository) row.matchedBy = "repository"
55
+ else if (parsed.kind === "submission" && titleTemplate && String(subject.title || "").startsWith(titleTemplate)) {
56
+ const titled = String(subject.title).slice(titleTemplate.length).trim().toLowerCase()
57
+ if (name && titled === name) row.matchedBy = "name"
58
+ else if (id && String(subject.body || "").includes(id)) row.matchedBy = "id"
59
+ }
60
+ if (row.matchedBy) matched.push(row)
37
61
  }
38
62
  }
39
63
  await Promise.all(Array.from({ length: Math.min(4, discovery.issues.length) }, worker))
40
- return { count, reason: `watch --all discovery: ${count} open issue(s) for this repository` }
64
+ matched.sort((a, b) => a.number - b.number)
65
+ const other = matched.filter((row) => !row.sameRepository).length
66
+ return { count: matched.length, account: discovery.account, issues: matched,
67
+ reason: `watch --all discovery: ${matched.length} open issue(s) for this plugin${other ? `, ${other} of them naming another repository` : ""}` }
41
68
  } catch (error) {
42
69
  return { count: null, reason: `not checked (${error.code || "issue-discovery-unavailable"}): ${error.message}` }
43
70
  }
@@ -437,6 +437,33 @@ export async function submitPreflight(options) {
437
437
  : null,
438
438
  }))
439
439
 
440
+ // --- the author's own open issue --------------------------------------------
441
+ // Discovered once, here, for two readers: the check below, and review.cost.
442
+ // On the subject's own listing the check is omitted with the other body
443
+ // checks: there is no submission to retry.
444
+
445
+ if (!options.offline) phase("reading your open marketplace issues")
446
+ const openIssues = await openIssuesForRepository({ repoRoot, repository: subject.repository.url, pluginName, pluginId: tree.pluginId,
447
+ offline: options.offline === true, github: options.github })
448
+ const ownIssues = openIssues.issues || []
449
+ const mismatched = ownIssues.filter((row) => !row.sameRepository)
450
+ const originForIssue = subject.repository.url || "the plugin's origin"
451
+ if (!listing) checks.push(check("submission.issue-repository-url", {
452
+ source: "omakit",
453
+ why: "The marketplace validates the Repository URL in the issue, and a retry is an edit to that issue. On omacom/omarchy-plugin-marketplace#7787 (2026-09-20, 19:18 UTC) a retry edit typed by hand put mtolhuijs where origin says mtolhuys, and the marketplace refused it as repository-unreachable 40 seconds after the edit event. M15, measured 2026-09-20 over all 651 open submission issues: 27 of the 646 with a readable URL name an owner other than the issue's author, so the URL is compared with the plugin's own origin, never with the author's login. Not a marketplace rule; an Omakit check that the issue says what origin says.",
454
+ severity: "blocking",
455
+ skipped: openIssues.count === null || ownIssues.length === 0,
456
+ verdict: mismatched.length === 0,
457
+ detail: openIssues.count === null
458
+ ? openIssues.reason
459
+ : ownIssues.length === 0
460
+ ? `no open submission issue by ${openIssues.account} for this plugin`
461
+ : mismatched.length
462
+ ? mismatched.map((row) => `issue #${row.number} says ${row.repositoryUrl}, origin says ${originForIssue}`).join("; ")
463
+ : ownIssues.map((row) => `issue #${row.number} matches origin`).join("; "),
464
+ remedy: mismatched.length ? mismatched.map((row) => `Edit issue #${row.number} and set the Repository URL field to ${originForIssue}. Change nothing else.`) : null,
465
+ }))
466
+
440
467
  // --- the baseline preflight ----------------------------------------------
441
468
 
442
469
  phase("running the official security baseline over a local snapshot")
@@ -470,8 +497,7 @@ export async function submitPreflight(options) {
470
497
 
471
498
  const policy = await reviewPolicy(repoRoot)
472
499
  const review = reviewCostVerdict({ baseline: consequence, policy,
473
- openIssues: consequence?.outcome === policy.manual ? await openIssuesForRepository({ repoRoot, repository: subject.repository.url,
474
- offline: options.offline === true, github: options.github }) : { count: null, reason: consequence?.outcome === policy.automated ? "open issue count not checked: automated baseline" : preflight.skipReason || preflight.refusal?.message || "baseline outcome needs findings resolved" },
500
+ openIssues: consequence?.outcome === policy.manual ? openIssues : { count: null, reason: consequence?.outcome === policy.automated ? "open issue count not checked: automated baseline" : preflight.skipReason || preflight.refusal?.message || "baseline outcome needs findings resolved" },
475
501
  why: `MEASUREMENTS.md M4: ${figure(figures.outcomes[policy.manual] || 0)} of ${figure(figures.withBaseline)} recorded listing baselines required review at the pin. M9: on 2026-09-15, 140 of 307 open update issues carried the manual-review label; 4 of 139 compared validated diffs were docs-only, with 1 unavailable. The baseline scans the whole snapshot, not the update diff, so unchanged capabilities also require another review. Sources and exact marketplace HEAD are recorded in MEASUREMENTS.md.`,
476
502
  })
477
503
  checks.push(review.check)
@@ -74,10 +74,13 @@ export const COMMANDS = Object.freeze([
74
74
  "omakit submit <target> --category <c> --tags <a,b> [--notes <text>]",
75
75
  " [--suggest-tag <t>] [--name <n>] [--offline]",
76
76
  " [--allow-dirty] [--json] [--out <file>]",
77
+ " [--body-out <file>]",
77
78
  ],
78
79
  lines: [
79
80
  "Every check that is knowable before submitting, the resolved commit, and",
80
81
  "the exact issue title and body. Prints them. Never posts anything.",
82
+ "--body-out writes the rendered body, and nothing else, to a file, for a",
83
+ "retry edit a person makes with `gh`'s `issue edit --body-file`.",
81
84
  "Three outcomes: READY (exit 0, the body), REFUSED (exit 1, no body), and",
82
85
  "LISTED (exit 0): the plugin is already listed by its own repository, so",
83
86
  "the submission form is not the route and nothing is asked. An id taken",
@@ -89,13 +92,17 @@ export const COMMANDS = Object.freeze([
89
92
  },
90
93
  {
91
94
  signature: [
92
- "omakit watch <issue-url> [--json] [--out <file>]",
95
+ "omakit watch <issue-url> [<subject>] [--json] [--out <file>]",
93
96
  "omakit watch [--all | --list] [--user <login>] [--json] [--out <file>]",
94
97
  ],
95
98
  lines: [
96
99
  "Compare the commit the marketplace validated on a submission issue with",
97
100
  "the plugin repository's current default-branch HEAD, and say what makes",
98
- "it validate a newer one. Read-only.",
101
+ "it validate a newer one. Read-only. <subject> is the plugin's checkout",
102
+ "or its github.com URL (default: the current directory when it is one",
103
+ "and its manifest is the issue's plugin): the issue's Repository URL is",
104
+ "compared with that origin, and a failed",
105
+ "validation is read back as the marketplace's own code.",
99
106
  "--all checks your open marketplace issues; --list lists them first.",
100
107
  "Without a URL or either flag, a terminal asks which issues to check.",
101
108
  "Uses your gh account, or --user to read another public account. JSON",
@@ -20,10 +20,14 @@
20
20
 
21
21
  import { join } from "node:path"
22
22
  import { pathToFileURL } from "node:url"
23
+ import { readFileSync } from "node:fs"
23
24
  import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
24
25
  import { authenticatedUser, repositoryIssues, defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
25
26
  import { liveRegistry } from "./registry.mjs"
26
27
  import { reviewPolicy, validatedDocumentationDiff } from "./review-cost.mjs"
28
+ import { parseGitHubUrl, resolveSubject, SubjectError } from "../subject/resolve.mjs"
29
+ import { omakitCacheDir } from "./paths.mjs"
30
+ import { submissionContract } from "./form.mjs"
27
31
 
28
32
  export class WatchError extends Error {
29
33
  constructor(code, message) {
@@ -97,7 +101,9 @@ export async function validationWatchAll({ repoRoot, discovery, github = {}, rea
97
101
  }
98
102
  }
99
103
  await Promise.all(Array.from({ length: Math.min(4, discovery.issues.length) }, worker))
100
- const summary = { total: results.length, current: 0, stale: 0, unknown: 0 }
104
+ // No subject in a batch, so no row can be wrong-repository; a refused
105
+ // row is one the marketplace answered with a failure, and is counted.
106
+ const summary = { total: results.length, current: 0, stale: 0, refused: 0, unknown: 0 }
101
107
  for (const result of results) summary[result.report?.verdict.state || "unknown"] += 1
102
108
  // M9: count labels on the listed issues, then compare only updates in the manual queue.
103
109
  const manual = results.filter((row) => {
@@ -187,24 +193,167 @@ export async function repositoryFor(pinDir, subject) {
187
193
  return { url: null, kind: null, error: errors.join("; ") }
188
194
  }
189
195
 
196
+ /** The last marketplace validation comment on the issue, or null. */
197
+ function lastValidationComment(comments) {
198
+ return (comments || [])
199
+ .filter((comment) => String(comment.body || "").includes("<!-- marketplace-validation -->"))
200
+ .at(-1) || null
201
+ }
202
+
190
203
  /** The short commit the validation comment reports, as a fallback when no baseline marker exists. */
191
204
  export function validationCommentCommit(comments) {
192
- const validation = (comments || [])
193
- .filter((comment) => String(comment.body || "").includes("<!-- marketplace-validation -->"))
194
- .at(-1)
205
+ const validation = lastValidationComment(comments)
195
206
  if (!validation) return null
196
207
  const short = String(validation.body).match(/passed at commit `([0-9a-f]{7,40})`/i)?.[1]
197
208
  return short ? { short: short.toLowerCase(), createdAt: validation.created_at || null } : null
198
209
  }
199
210
 
211
+ /**
212
+ * The marketplace's own failure table, read from the pin: every code
213
+ * `publicSubmissionFailure` can answer with, and the reason and action it
214
+ * prints for each. The table itself is not exported by the pinned module,
215
+ * so the codes are read out of its text and each is put through the
216
+ * exported function, which is the same path the failure comment took.
217
+ */
218
+ export async function submissionFeedback(pinDir) {
219
+ const path = join(pinDir, "scripts/submission-feedback.mjs")
220
+ const { publicSubmissionFailure } = await import(pathToFileURL(path).href)
221
+ const codes = [...readFileSync(path, "utf8").matchAll(/^ "([a-z][a-z0-9-]*)": \{$/gm)].map((match) => match[1])
222
+ return codes.map((code) => ({ code, ...publicSubmissionFailure({ code }) }))
223
+ }
224
+
225
+ /**
226
+ * What the last validation comment says: `passed` with the short commit, or
227
+ * `failed` with the reason mapped back to the marketplace's code through
228
+ * the pinned feedback table. The marketplace edits its validation comment
229
+ * in place on every run (measured on #7787: the comment created at
230
+ * 13:38:35 carried `updated_at` 19:41:00 after three validations), so the
231
+ * time of a refusal is the comment's `updated_at`, not its `created_at`.
232
+ *
233
+ * @param {object[]} comments
234
+ * @param {{ code: string, reason: string, action: string }[]} feedback from submissionFeedback()
235
+ */
236
+ export function validationComment(comments, feedback = []) {
237
+ const validation = lastValidationComment(comments)
238
+ if (!validation) return null
239
+ const body = String(validation.body)
240
+ const at = validation.updated_at || validation.created_at || null
241
+ const passed = body.match(/passed at commit `([0-9a-f]{7,40})`/i)?.[1]
242
+ if (passed) return { kind: "passed", short: passed.toLowerCase(), at, createdAt: validation.created_at || null }
243
+ // `❌ **Validation failed:** <reason>` then a blank line and the action;
244
+ // the bold markers are the marketplace's Markdown and are not part of
245
+ // the reason. A comment that is neither is reported as it is.
246
+ const failed = body.match(/Validation failed:\*{0,2}\s*(.+)/)
247
+ if (!failed) return { kind: "unrecognised", at, createdAt: validation.created_at || null, text: body.replace(/<!--.*?-->/gs, "").trim().slice(0, 500) }
248
+ const reason = failed[1].replace(/\*+/g, "").trim()
249
+ const rest = body.slice(failed.index + failed[0].length).replace(/\*+/g, "").trim()
250
+ const known = feedback.find((entry) => entry.reason === reason || entry.reason.replace(/\.$/, "") === reason.replace(/\.$/, ""))
251
+ return {
252
+ kind: "failed",
253
+ code: known ? known.code : "unrecognised",
254
+ reason,
255
+ action: known ? known.action : rest.split(/\n\s*\n/)[0] || null,
256
+ at,
257
+ createdAt: validation.created_at || null,
258
+ }
259
+ }
260
+
261
+ /**
262
+ * The labels the marketplace puts on a submission, read from the pin: the
263
+ * two blocking labels from the baseline policy, `validated` from the label
264
+ * set the approval script requires, and the manual-review label from the
265
+ * review policy.
266
+ */
267
+ export async function marketplaceLabels(pinDir) {
268
+ const policy = await import(pathToFileURL(join(pinDir, "scripts/security-baseline-policy.mjs")).href)
269
+ const approval = readFileSync(join(pinDir, "scripts/approve-submission.mjs"), "utf8")
270
+ const validated = approval.match(/for \(const required of \["submission", "([^"]+)"/)?.[1]
271
+ if (!validated) throw new Error("cannot read the validated label from the pin")
272
+ const manual = policy.currentSecurityBaselinePolicy.maintainerVerificationOutcome
273
+ return { blocking: [...policy.securityBaselineBlockingLabels], validated, reviewRequired: `security-${manual}` }
274
+ }
275
+
276
+ /**
277
+ * The origin a watch compares the issue with. A github.com URL is taken as
278
+ * given; a path is a Git checkout whose `origin` is read the way `submit`
279
+ * reads it, and the working tree may be dirty, since only the remote is
280
+ * read. With no argument the current directory is the subject when it is
281
+ * such a checkout, and there is no subject otherwise. A cwd subject also
282
+ * carries its root manifest's name and id, because it was not asserted by
283
+ * anyone: validationWatch() compares it only when the issue is that plugin.
284
+ *
285
+ * @returns {{ origin: string, source: "url"|"path"|"cwd", manifest?: { name: string, id: string }|null }|null}
286
+ */
287
+ export function resolveWatchSubject(target, { cwd = process.cwd(), cacheRoot = omakitCacheDir() } = {}) {
288
+ if (target !== undefined) {
289
+ const gh = /^(?:https:\/\/|git@)/.test(String(target)) ? parseGitHubUrl(target) : null
290
+ if (gh) return { origin: gh.url, source: "url" }
291
+ if (/^https?:\/\//.test(String(target))) throw new WatchError("usage", `the subject is a github.com repository URL or a local checkout, got ${target}`)
292
+ const subject = resolveSubject(target, { cacheRoot, allowDirty: true })
293
+ if (!subject.repository.url) throw new SubjectError("no-origin", `${subject.dir} has no github.com origin remote, so there is no repository to compare the issue with`)
294
+ return { origin: subject.repository.url, source: "path" }
295
+ }
296
+ try {
297
+ const subject = resolveSubject(cwd, { cacheRoot, allowDirty: true })
298
+ return subject.repository.url ? { origin: subject.repository.url, source: "cwd", manifest: rootManifest(subject.dir) } : null
299
+ } catch {
300
+ return null
301
+ }
302
+ }
303
+
304
+ /** The name and id of the root manifest.json, or null when there is none or it does not parse. */
305
+ function rootManifest(dir) {
306
+ try {
307
+ const manifest = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"))
308
+ return { name: String(manifest?.name || "").trim(), id: String(manifest?.id || "").trim() }
309
+ } catch {
310
+ return null
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Whether an implicit (cwd) subject is the plugin the issue is about: the
316
+ * issue title carries the manifest's name after the pinned title template,
317
+ * or the body names the manifest's id. An explicit subject is the caller's
318
+ * assertion and always applies. Reviewed on the branch before release: run
319
+ * from the omakit checkout itself, `omakit watch <omacrunch issue>` compared
320
+ * the issue with omakit's origin, reported wrong-repository, and printed an
321
+ * action to put omakit's URL into omacrunch's issue; the #7787 mistake with
322
+ * the tool's authority behind it.
323
+ *
324
+ * @returns {boolean}
325
+ */
326
+ export function subjectApplies(watchSubject, issue, titleTemplate) {
327
+ if (!watchSubject) return false
328
+ if (watchSubject.source !== "cwd") return true
329
+ const manifest = watchSubject.manifest
330
+ if (!manifest) return false
331
+ const title = String(issue?.title || "")
332
+ if (manifest.name && titleTemplate && title.startsWith(titleTemplate)) {
333
+ if (title.slice(titleTemplate.length).trim().toLowerCase() === manifest.name.toLowerCase()) return true
334
+ }
335
+ return Boolean(manifest.id) && String(issue?.body || "").includes(manifest.id)
336
+ }
337
+
338
+ /** Two github.com repository URLs name the same repository: https, no `.git`, no trailing slash, owner and name case-insensitively. */
339
+ export function sameRepository(a, b) {
340
+ const left = parseGitHubUrl(a)
341
+ const right = parseGitHubUrl(b)
342
+ if (!left || !right) return null
343
+ return left.owner.toLowerCase() === right.owner.toLowerCase() && left.repository.toLowerCase() === right.repository.toLowerCase()
344
+ }
345
+
200
346
  /**
201
347
  * @param {{ repoRoot: string, issueUrl: string, onPhase?: (name: string) => void,
348
+ * subject?: { origin: string, source?: string }|null,
202
349
  * github?: { issue?: typeof issue, issueComments?: typeof issueComments, defaultBranchHead?: typeof defaultBranchHead } }} options
203
350
  * `github` is injectable for tests, so the whole path from an issue body to
204
351
  * a verdict can be run on data that never left the machine; the default is
205
- * the read-only GitHub access in `github.mjs`.
352
+ * the read-only GitHub access in `github.mjs`. `subject` is the plugin's
353
+ * own origin, from resolveWatchSubject(); with one, the issue's Repository
354
+ * URL is compared with it, and without one (`watch --all`) it is not.
206
355
  */
207
- export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {} }) {
356
+ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}, subject: watchSubject = null }) {
208
357
  // Optional: told the name of the step about to run, so a terminal can say
209
358
  // what is happening while the network answers. Never affects the result.
210
359
  const phase = onPhase || (() => {})
@@ -229,6 +378,22 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
229
378
  const repositoryUrl = repository.url
230
379
  const repositoryError = repository.error
231
380
  const issueKind = repository.kind
381
+ // #7787, 2026-09-20 19:18 UTC: a retry edit typed the Repository URL as
382
+ // mtolhuijs/omacrunch on an issue whose plugin lives at mtolhuys/omacrunch.
383
+ // The marketplace validates the URL in the issue, so it validated a
384
+ // repository that does not exist and refused within 40 seconds. The issue
385
+ // is compared with origin here, so the typo is named as what it is rather
386
+ // than reported as a HEAD that could not be read.
387
+ // A subject nobody named (the current directory) is compared only when
388
+ // it is this plugin; otherwise it is reported as not compared, with the
389
+ // directory's origin, so a person sees why and can pass the plugin.
390
+ const titleTemplate = watchSubject?.source === "cwd" ? (await submissionContract({ pinDir })).titleTemplate : ""
391
+ const applies = subjectApplies(watchSubject, subject, titleTemplate)
392
+ const origin = applies ? watchSubject.origin : null
393
+ const subjectSkipped = watchSubject && !applies
394
+ ? { origin: watchSubject.origin, reason: `the current directory is ${watchSubject.origin}, not this plugin; pass the plugin checkout or URL as the second argument` }
395
+ : null
396
+ const repositoryMatches = origin && repositoryUrl ? sameRepository(repositoryUrl, origin) : null
232
397
 
233
398
  let validated = null
234
399
  let baselineError = null
@@ -249,6 +414,18 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
249
414
  baselineError = { code: error.code || "baseline-unreadable", message: error.message }
250
415
  }
251
416
  const fallback = validated ? null : validationCommentCommit(comments)
417
+ const validation = validationComment(comments, await submissionFeedback(pinDir))
418
+ const labelNames = await marketplaceLabels(pinDir)
419
+ // A refusal is the current state when it is newer than the baseline
420
+ // marker, and the marker wins when it is newer. On #7787 the marker said
421
+ // checkedAt 16:12 (the hand-edited retry that still validated) and the
422
+ // validation comment said "Validation failed" with updated_at 19:19:13
423
+ // (the typed retry); the marker was three hours stale and the refusal
424
+ // was the state of the issue. The next validation at 19:34 wrote a newer
425
+ // marker, and from then on the marker was the state again.
426
+ const refusal = validation?.kind === "failed" && (!validated || Date.parse(validation.at || "") > Date.parse(validated.checkedAt || ""))
427
+ ? { code: validation.code, reason: validation.reason, action: validation.action, at: validation.at }
428
+ : null
252
429
  let previousValidated = null
253
430
  if (validated) {
254
431
  // Revalidation on an existing issue may precede the latest marker. Ignore
@@ -265,6 +442,11 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
265
442
  }
266
443
 
267
444
  const labels = (subject.labels || []).map((label) => (typeof label === "string" ? label : label?.name)).filter(Boolean)
445
+ const labelState = {
446
+ blocking: labels.filter((label) => labelNames.blocking.includes(label)),
447
+ validated: labels.includes(labelNames.validated),
448
+ reviewRequired: labels.includes(labelNames.reviewRequired),
449
+ }
268
450
  const authorComments = comments.filter((comment) => comment?.user?.login === subject.user?.login)
269
451
  // The discussion is a person's: the marketplace's own bot and any other
270
452
  // automation (GitHub marks an app's account `type: "Bot"`, and names it
@@ -311,10 +493,13 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
311
493
  lastMaintainerCommentAt: maintainerComments.at(-1)?.created_at || null,
312
494
  authenticated: Boolean(token()),
313
495
  },
314
- plugin: { repository: repositoryUrl, repositoryError, form: issueKind },
496
+ plugin: { repository: repositoryUrl, repositoryError, form: issueKind, origin, repositoryMatches, subjectSkipped },
315
497
  validated,
316
498
  previousValidated,
499
+ validationComment: validation,
317
500
  validationCommentFallback: fallback,
501
+ refusal,
502
+ labelState,
318
503
  baselineError,
319
504
  head,
320
505
  headError,
@@ -324,7 +509,7 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
324
509
  createdAt: maintainerComments.at(-1).created_at || null,
325
510
  authorAssociation: maintainerComments.at(-1).author_association || null,
326
511
  } : null,
327
- verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }),
512
+ verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl, origin, repositoryMatches, refusal }),
328
513
  }
329
514
  }
330
515
 
@@ -333,7 +518,24 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
333
518
  // truthy string "unknown", so an issue whose body named no repository was
334
519
  // reported as a HEAD that could not be read, and the branch below that names
335
520
  // the real cause was reachable from the unit test alone.
336
- export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }) {
521
+ export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl, origin = null, repositoryMatches = null, refusal = null }) {
522
+ // The two states that say the issue itself is wrong come first: a
523
+ // comparison of commits on the wrong repository, or after a refusal,
524
+ // describes nothing the marketplace is looking at.
525
+ if (repositoryMatches === false) {
526
+ return {
527
+ state: "wrong-repository",
528
+ summary: `The issue's Repository URL is ${repositoryUrl}, the plugin's origin is ${origin}. The marketplace validates the URL in the issue, so it is validating the wrong repository, or none.`,
529
+ action: `Edit the issue and set the Repository URL field to ${origin}. Change nothing else.`,
530
+ }
531
+ }
532
+ if (refusal) {
533
+ return {
534
+ state: "refused",
535
+ summary: `The marketplace refused this issue at ${refusal.at || "an unrecorded time"}: ${refusal.code}, ${refusal.reason}${refusal.action ? ` ${refusal.action}` : ""}`,
536
+ action: refusal.action || REFRESH_ACTION,
537
+ }
538
+ }
337
539
  if (baselineError) {
338
540
  return {
339
541
  state: "unknown",