omakit 0.1.6 → 0.1.7

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
@@ -134,16 +134,32 @@ file and line, and the official text verbatim, then the marketplace's own
134
134
  statement. `--json` prints the document itself, unchanged from earlier
135
135
  releases, and `--out <file>` writes it; agents and the skills use those.
136
136
 
137
- `omakit submit` reads the marketplace's registry first. A plugin that is
138
- already listed is refused there and asked for nothing (measured on 0.1.5: it
139
- exited 2 asking for `--category` and `--tags`, then would have said there was
140
- nothing to submit). An unlisted plugin needs both, and they are an editorial
137
+ `omakit submit` reads the marketplace's registry first, and a run has three
138
+ outcomes. `READY`, exit 0: every blocking check passed and the title and body
139
+ follow. `REFUSED`, exit 1: a blocking check failed and no body is produced.
140
+ `LISTED`, exit 0: the plugin is already listed by its own repository (the
141
+ manifest id is in the catalog, and the listing's repository is the subject's
142
+ declared `origin`, compared as owner and name), so the submission form is not
143
+ the route. Nothing is wrong and nothing was refused: `identity.available`
144
+ passes with the listing's record (since when, which commit, verified or not),
145
+ the five checks that exist only for the body are omitted, nothing is asked,
146
+ and the closing block names the commit the marketplace lists, the local
147
+ commit, whether they are the same, and the marketplace's verification form
148
+ with the choice that lists a newer commit, read from the pin's
149
+ `verify-plugin.yml`. Measured on 0.1.6: this state printed `FAIL
150
+ identity.available`, `REFUSED`, and "Fix it, then run submit again" under a
151
+ remedy that said there was nothing to submit. An id taken by another
152
+ repository, a retired id or a reserved one is still refused. In `--json`, the
153
+ outcome is `outcome: "ready" | "refused" | "listed"`, `ready` stays a boolean
154
+ that is true for the first only, and a listed run carries a `listing` object.
155
+
156
+ An unlisted plugin needs a category and tags, and they are an editorial
141
157
  choice nobody else can make: at a terminal it asks, once each, with the form's
142
158
  own lists numbered and the marketplace's own default for the manifest's kinds
143
159
  offered where it is on the list; in a pipe, from an agent, or with `--json` it
144
- is the usage error with the same lists, exit 2. Either way the report ends
145
- with the command line that repeats the run without asking, and `--json`
146
- carries it as `reproduce`.
160
+ is the usage error with the same lists, exit 2. A listed plugin is asked for
161
+ neither. A `READY` or `REFUSED` report ends with the command line that repeats
162
+ the run without asking, and `--json` carries it as `reproduce`.
147
163
 
148
164
  There is nothing to authenticate. If you have `gh auth login` done, omakit
149
165
  reads that credential for GET requests and stores nothing; a token in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only, posts nothing, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -46,9 +46,9 @@ message lists the controlled values, read from the pinned form; with `--json`
46
46
  the same lists come back under `usage`. A person at a terminal is asked
47
47
  instead, once each, with the marketplace's own default for the manifest's
48
48
  kinds. It is decided after the registry is read: a plugin that is already
49
- listed is refused at `identity.available` and asked for nothing. Every report
50
- ends with the command line that repeats the run without asking, and `--json`
51
- carries it as `reproduce`.
49
+ listed is asked for nothing. A `READY` or `REFUSED` report ends with the
50
+ command line that repeats the run without asking, and `--json` carries it as
51
+ `reproduce`.
52
52
 
53
53
  To see the official baseline result alone, with no Omakit check around it:
54
54
 
@@ -72,9 +72,34 @@ HEAD, tell the owner and choose another id.
72
72
 
73
73
  ## Reading the result
74
74
 
75
- Exit code 0 means every blocking check passed and the output contains the issue
76
- title and body. Exit code 1 means it refused, and no body was produced. Exit
77
- code 2 is a usage error: nothing was checked.
75
+ A run ends one of three ways; `--json` carries it as `outcome`.
76
+
77
+ - `READY`, exit 0, `outcome: "ready"`: every blocking check passed and the
78
+ output contains the issue title and body.
79
+ - `REFUSED`, exit 1, `outcome: "refused"`: a blocking check failed and no body
80
+ was produced.
81
+ - `LISTED`, exit 0, `outcome: "listed"`: the plugin is already listed by its
82
+ own repository. Nothing is wrong and nothing was refused, and there is no
83
+ body, because the submission form is not the route. See below.
84
+
85
+ Exit code 2 is a usage error: nothing was checked.
86
+
87
+ **If the outcome is `listed`, stop.** Do not open a submission issue, and
88
+ never change the plugin id to get past it: the id is listed by this very
89
+ repository, and a renamed id would be a second listing of the same plugin.
90
+ Tell the owner the update route, which the output states: the marketplace
91
+ lists `verificationCommit`; the local commit is `localCommit`, and
92
+ `sameCommit` says whether they are the same; to get a newer commit listed,
93
+ open the marketplace's verification form (its name and the choice to pick,
94
+ "Verify and publish a newer upstream commit", are printed from the pin's own
95
+ form, under `listing.updateRoute` in `--json`), and `omakit watch <the
96
+ submission issue>` shows which commit is listed now. A listed plugin is asked
97
+ for no category and no tags, and the five body checks are omitted rather than
98
+ shown as waiting.
99
+
100
+ An id listed by a *different* repository is a refusal, `identity.available`
101
+ names that repository, and the remedy is another id. That is the one case
102
+ where changing the id is the fix.
78
103
 
79
104
  A check drawn as `▒ ?` did not run because one it depends on failed; its detail
80
105
  names that check. It is not a failure of its own, and the closing refusal lists
@@ -44,7 +44,11 @@ it from the operator's `gh` login (which itself honours `GH_TOKEN` and
44
44
  If the diagnosis ends in a resubmission under a new id, run `omakit submit`
45
45
  online: never pass `--offline` to get around a listed id, because that reads
46
46
  the listed ids from the pin, which is stale within hours, and the marketplace
47
- refuses against its current registry.
47
+ refuses against its current registry. If `omakit submit` ends `LISTED`
48
+ (`outcome: "listed"` in `--json`), the plugin is already listed by its own
49
+ repository: stop, do not change the id, and tell the owner the update route
50
+ the output names, the marketplace's verification form with the choice "Verify
51
+ and publish a newer upstream commit", read from the pin.
48
52
 
49
53
  ## Acting on each verdict
50
54
 
@@ -13,13 +13,13 @@ local commit through the transport seam the marketplace tests itself
13
13
  | `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. `omakit verify` renders it for a person (`renderVerify` in `report.mjs`) and prints the document itself behind `--json` and `--out`. |
14
14
  | `preflight.mjs` | Translates that result into what it will cause on submission, using the pinned policy, and renders the marketplace's own report text with its attestation marker stripped and asserted absent. |
15
15
  | `yaml.mjs` | A deliberately small YAML reader for the pinned issue form. Accepts that subset and throws on anything else. |
16
- | `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. |
17
- | `registry.mjs` | The plugin-id and repository universe: the reserved namespace from the pinned catalog builder, the listed and retired ids and listed repositories from `registry.json` and `site/catalog.json` at the marketplace's current HEAD when the network is there (cached under `$XDG_CACHE_HOME/omakit/registry/<commit>/`, never in the pin) and at the pin with `--offline`; `liveFileUrl()` is the only way to the raw file host, at a 40-character commit, for those two files. |
16
+ | `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. Also the route for a plugin that is already listed: the marketplace's verification form and its "newer commit" choice, read from `verify-plugin.yml` at the pin and cross-checked against `plugin-verification-request.mjs`. |
17
+ | `registry.mjs` | The plugin-id and repository universe: the reserved namespace from the pinned catalog builder, the listed and retired ids and listed repositories from `registry.json` and `site/catalog.json` at the marketplace's current HEAD when the network is there (cached under `$XDG_CACHE_HOME/omakit/registry/<commit>/`, never in the pin) and at the pin with `--offline`; `liveFileUrl()` is the only way to the raw file host, at a 40-character commit, for those two files. `sameRepository()` is the one rule for "the subject's own repository" (owner and name, case-insensitively, a trailing `.git` ignored), and `listingOf()` is what the catalog records about a listing: since when, which commit, verified or not, checked when. |
18
18
  | `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
19
19
  | `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
20
20
  | `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
21
21
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
22
- | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Decides the category and tags after the registry: a listed plugin needs neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. |
22
+ | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Three outcomes: `ready` (the body), `refused` (a blocking check failed) and `listed` (the plugin is already listed by its own repository: `identity.available` passes with the listing's record, the five body checks are omitted rather than drawn as waiting, no body exists on purpose, and `listing` carries the listed commit against the local one and the form to use for a newer commit). Decides the category and tags after the registry: a listed plugin, own or taken, is asked for neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. |
23
23
  | `ask.mjs` | The two questions `submit` asks a person at a terminal, and only there: category and tags, numbered from the pinned form, with the marketplace's own presentation for the manifest's kinds (read from the pinned catalog builder) as the default where it is on the list. Prompts on stderr, nothing persisted. |
24
24
  | `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
25
25
  | `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
@@ -31,7 +31,7 @@ local commit through the transport seam the marketplace tests itself
31
31
  | `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
32
32
  | `effect.mjs` | The one text effect: the wordmark through `ttfx` where it is drawn, with frozen arguments, a hard budget, no colour of its own, and nothing at all when `ttfx` is not there. |
33
33
  | `progress.mjs` | The progress line, on stderr, only when a person is looking. |
34
- | `cli.mjs` | The one entry point behind `bin/omakit`, and the one register every failure is reported in. |
34
+ | `cli.mjs` | The one entry point behind `bin/omakit`, and the one register every failure is reported in. `submit` exits on the outcome: 1 for `refused`, 0 for `ready` and `listed`. |
35
35
 
36
36
  ```text
37
37
  omakit pin
@@ -148,7 +148,9 @@ async function cmdSubmit(args) {
148
148
  }
149
149
  spinner.done()
150
150
  emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderSubmit(result))
151
- process.exit(result.ready ? 0 : 1)
151
+ // Three outcomes, two exit codes: `ready` and `listed` are both healthy
152
+ // states, and only a refusal is a 1.
153
+ process.exit(result.outcome === "refused" ? 1 : 0)
152
154
  }
153
155
 
154
156
  async function cmdWatch(args) {
@@ -27,6 +27,8 @@ import { requirePin } from "./pin.mjs"
27
27
 
28
28
  export const SUBMIT_FORM_PATH = ".github/ISSUE_TEMPLATE/submit-plugin.yml"
29
29
  export const OFFICIAL_SUBMISSION_MODULE = "scripts/submission.mjs"
30
+ export const VERIFY_FORM_PATH = ".github/ISSUE_TEMPLATE/verify-plugin.yml"
31
+ export const OFFICIAL_VERIFICATION_MODULE = "scripts/plugin-verification-request.mjs"
30
32
 
31
33
  export class ContractError extends Error {
32
34
  constructor(code, message) {
@@ -50,8 +52,8 @@ function loadOfficial(pinDir) {
50
52
  return import(pathToFileURL(join(pinDir, OFFICIAL_SUBMISSION_MODULE)).href)
51
53
  }
52
54
 
53
- function fieldsOf(form) {
54
- if (!Array.isArray(form?.body)) throw new ContractError("form-unreadable", `${SUBMIT_FORM_PATH} has no body`)
55
+ function fieldsOf(form, formPath = SUBMIT_FORM_PATH) {
56
+ if (!Array.isArray(form?.body)) throw new ContractError("form-unreadable", `${formPath} has no body`)
55
57
  return form.body
56
58
  .filter((item) => item && item.type !== "markdown")
57
59
  .map((item) => ({
@@ -65,12 +67,12 @@ function fieldsOf(form) {
65
67
  }))
66
68
  }
67
69
 
68
- function only(fields, predicate, what) {
70
+ function only(fields, predicate, what, formPath = SUBMIT_FORM_PATH) {
69
71
  const found = fields.filter(predicate)
70
72
  if (found.length !== 1) {
71
73
  throw new ContractError(
72
74
  "form-shape-changed",
73
- `${SUBMIT_FORM_PATH} no longer has exactly one ${what} (found ${found.length}); the pin changed shape and the submission contract must be re-read before anything is generated`,
75
+ `${formPath} no longer has exactly one ${what} (found ${found.length}); the pin changed shape and the submission contract must be re-read before anything is generated`,
74
76
  )
75
77
  }
76
78
  return found[0]
@@ -135,6 +137,36 @@ export async function submissionContract(options = {}) {
135
137
  return contract
136
138
  }
137
139
 
140
+ /**
141
+ * The route for a plugin that is already listed: the marketplace's other
142
+ * form, and the one choice on it that lists a newer commit. Both read from
143
+ * the pin. The choice text is the option as the form spells it, found by the
144
+ * name the marketplace's own verification module gives that action; the two
145
+ * must agree, the way the submission form and `scripts/submission.mjs` must,
146
+ * because a choice retyped here would drift by a word and send a person to
147
+ * pick something the form no longer offers.
148
+ *
149
+ * @param {{ repoRoot?: string, pinDir?: string }} [options]
150
+ * @returns {Promise<{ formPath: string, name: string, choice: string }>}
151
+ */
152
+ export async function newerCommitChoice(options = {}) {
153
+ const pinDir = options.pinDir || requirePin(options.repoRoot).dir
154
+ const form = parseYaml(readFileSync(join(pinDir, VERIFY_FORM_PATH), "utf8"))
155
+ const fields = fieldsOf(form, VERIFY_FORM_PATH)
156
+ const action = only(fields, (f) => f.type === "dropdown" && !f.multiple && f.required, "required single-select dropdown (verification action)", VERIFY_FORM_PATH)
157
+ const official = await import(pathToFileURL(join(pinDir, OFFICIAL_VERIFICATION_MODULE)).href)
158
+ const choice = (action.options || []).find((option) => option === official.upstreamUpdateVerificationAction)
159
+ if (!choice) {
160
+ throw new ContractError(
161
+ "form-shape-changed",
162
+ `${VERIFY_FORM_PATH} offers no "${official.upstreamUpdateVerificationAction}" under ${action.label}; the form and ${OFFICIAL_VERIFICATION_MODULE} at the pin disagree, and nobody is sent to a choice that is not there`,
163
+ )
164
+ }
165
+ const name = typeof form.name === "string" ? form.name.trim() : ""
166
+ if (!name) throw new ContractError("form-shape-changed", `${VERIFY_FORM_PATH} has no name`)
167
+ return { formPath: VERIFY_FORM_PATH, name, choice }
168
+ }
169
+
138
170
  /**
139
171
  * The form and the marketplace's own constants must describe the same
140
172
  * submission. Anything else means the pin is internally inconsistent, and
@@ -107,12 +107,32 @@ export function defaultPresentation(presentation, kinds = []) {
107
107
  return { category, tags }
108
108
  }
109
109
 
110
+ /**
111
+ * "https://github.com/Owner/Name.git" and "owner/name" both become
112
+ * "owner/name": the path, without surrounding slashes, without a trailing
113
+ * .git, lowercased. Anything else is "", which matches nothing.
114
+ */
110
115
  function repositorySlug(value) {
116
+ const text = String(value ?? "").trim()
117
+ let path
111
118
  try {
112
- return new URL(String(value)).pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase()
119
+ path = new URL(text).pathname
113
120
  } catch {
114
- return ""
121
+ path = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(text) ? text : ""
115
122
  }
123
+ return path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase()
124
+ }
125
+
126
+ /**
127
+ * The one rule for "the same repository": owner and name, compared
128
+ * case-insensitively, with a trailing .git ignored. Measured on 0.1.6: the
129
+ * author's own listed plugin was rendered as a failed check because the
130
+ * listing and the subject were compared as two strings, so this is the rule
131
+ * and its test, not a comparison typed where it is needed.
132
+ */
133
+ export function sameRepository(a, b) {
134
+ const slug = repositorySlug(a)
135
+ return Boolean(slug) && slug === repositorySlug(b)
116
136
  }
117
137
 
118
138
  /**
@@ -400,11 +420,14 @@ export function figure(n) {
400
420
 
401
421
  /**
402
422
  * @param {{ id: string, repositoryUrl?: string|null }} subject
403
- * @returns {{ ok: boolean, problems: Array<{ code: string, detail: string, repository?: string|null, sameRepository?: boolean }> }}
423
+ * @returns {{ ok: boolean, own: boolean, problems: Array<{ code: string, detail: string, repository?: string|null, sameRepository?: boolean }> }}
404
424
  * A `plugin-id-listed` problem names the repository that lists the id
405
425
  * (`repository`, a slug, or null when the registry does not say) and
406
426
  * whether that is the subject's own (`sameRepository`), because the two
407
- * have different remedies: another id, or nothing to submit at all.
427
+ * are different states: another id is needed, or the plugin is listed and
428
+ * there is nothing to submit. `own` is the second state on its own: the id
429
+ * is listed by this repository and no other code fired, so `ok` is false
430
+ * and nothing is wrong.
408
431
  */
409
432
  export function checkIdentity(universe, subject) {
410
433
  const problems = []
@@ -425,18 +448,64 @@ export function checkIdentity(universe, subject) {
425
448
  }
426
449
  if (universe.listedIds.has(id)) {
427
450
  const repository = universe.listedBy?.get(id) || null
428
- const sameRepository = Boolean(repository && slug && repository === slug)
429
451
  problems.push({
430
452
  code: "plugin-id-listed",
431
453
  detail: `"${id}" is already listed${repository ? ` by ${repository}` : ""}`,
432
454
  repository,
433
- sameRepository,
455
+ sameRepository: sameRepository(repository, slug),
434
456
  })
435
457
  }
436
458
  if (slug && universe.listedRepositories.has(slug)) {
437
459
  problems.push({ code: "submission-repository-listed", detail: `${slug} is already listed`, repository: slug, sameRepository: true })
438
460
  }
439
- return { ok: problems.length === 0, problems }
461
+ // The subject's own listing: the id is listed, by this repository, and
462
+ // nothing else is wrong with the id. That is not a problem with the
463
+ // submission, it is the absence of one; the caller reports it as a state.
464
+ const own = problems.length > 0
465
+ && problems.some((problem) => problem.code === "plugin-id-listed" && problem.sameRepository)
466
+ && problems.every((problem) => problem.sameRepository)
467
+ return { ok: problems.length === 0, own, problems }
468
+ }
469
+
470
+ /**
471
+ * What the marketplace records about one listing, for the plugin that is
472
+ * already listed by its own repository: read from the catalog first, which
473
+ * carries the verification fields, and from the registry source when the
474
+ * catalog has no entry. Every field is null when nothing records it; nothing
475
+ * is guessed.
476
+ *
477
+ * @param {{ registry: object, catalog: object }} live
478
+ * @param {string} id
479
+ * @returns {{ repository: string|null, id: string, addedAt: string|null, verificationCommit: string|null,
480
+ * verificationStatus: string|null, verificationCheckedAt: string|null }|null}
481
+ */
482
+ export function listingOf(live, id) {
483
+ const text = (value) => (typeof value === "string" && value ? value : null)
484
+ const plugin = (Array.isArray(live.catalog?.plugins) ? live.catalog.plugins : []).find((entry) => entry?.id === id)
485
+ if (plugin) {
486
+ return {
487
+ repository: text(plugin.repo),
488
+ id,
489
+ addedAt: text(plugin.addedAt) || text(plugin.listedAt),
490
+ verificationCommit: text(plugin.verificationCommit)?.toLowerCase() || text(plugin.listingValidatedCommit)?.toLowerCase() || null,
491
+ verificationStatus: text(plugin.verificationStatus),
492
+ verificationCheckedAt: text(plugin.verificationCheckedAt) || text(plugin.listingValidatedAt),
493
+ }
494
+ }
495
+ const sources = Array.isArray(live.registry?.sources) ? live.registry.sources : Object.values(live.registry?.sources || {})
496
+ const source = sources.find((entry) => {
497
+ const ids = entry?.plugins && typeof entry.plugins === "object" && !Array.isArray(entry.plugins) ? Object.keys(entry.plugins) : []
498
+ return ids.includes(id) || (entry?.automatedSecurityBaseline?.pluginIds || []).includes(id)
499
+ })
500
+ if (!source) return null
501
+ return {
502
+ repository: text(source.repo),
503
+ id,
504
+ addedAt: text(source.addedAt) || text(source.listedAt),
505
+ verificationCommit: text(source.listingValidatedCommit)?.toLowerCase() || null,
506
+ verificationStatus: text(source.automatedSecurityBaseline?.outcome),
507
+ verificationCheckedAt: text(source.listingValidatedAt),
508
+ }
440
509
  }
441
510
 
442
511
  export { repositorySlug }
@@ -122,7 +122,26 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
122
122
  out.push("")
123
123
  }
124
124
 
125
- if (!result.ready) {
125
+ if (result.outcome === "listed") {
126
+ // The third outcome. Nothing was refused and nothing is wrong: the plugin
127
+ // is listed by this repository, so there is no body and no reproduce
128
+ // line, and the route to a newer commit is the marketplace's other form.
129
+ // Measured on 0.1.6: this state was drawn as FAIL and REFUSED, and closed
130
+ // with "Fix it, then run submit again" under a remedy that said there was
131
+ // nothing to submit.
132
+ const listing = result.listing
133
+ out.push(...verdict("pass", "LISTED", `${listing.id} is already listed by this repository, so the submission form is not the route.`, c))
134
+ out.push("")
135
+ out.push(...field("listed", listing.verificationCommit ? c("name", listing.verificationCommit) : "no verification commit recorded", c, { wrapValue: false }))
136
+ out.push(...continuation(`${listing.verificationStatus || "status unrecorded"}, checked ${listing.verificationCheckedAt || "at an unrecorded time"}, read from the ${listing.source === "head" ? "marketplace's current HEAD" : "pin"}`, c))
137
+ out.push(...field("local HEAD", c("name", listing.localCommit), c, { wrapValue: false }))
138
+ out.push(...continuation(listing.sameCommit ? "the same commit" : "not the listed commit", c))
139
+ out.push("")
140
+ out.push(...wrap(`${listing.sameCommit ? "To get a newer commit listed later" : "To get it listed"}, open the marketplace's "${listing.updateRoute.form}" form and choose "${listing.updateRoute.choice}". \`omakit watch <the submission issue>\` shows which commit is listed now.`, {}, c))
141
+ return out.join("\n")
142
+ }
143
+
144
+ if (result.outcome === "refused") {
126
145
  // Root causes only: a check that waited on a failed one is not listed,
127
146
  // and the count says how many waited.
128
147
  const failed = result.checks.filter((check) => result.blocking.includes(check.id))
@@ -13,8 +13,8 @@
13
13
 
14
14
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
15
15
  import { requirePin } from "./pin.mjs"
16
- import { submissionContract, resolveCategory, resolveTags, tagSlug } from "./form.mjs"
17
- import { idUniverse, checkIdentity, baselineFigures, figure, liveRegistry, registrySourceDetail, catalogPresentation, defaultPresentation } from "./registry.mjs"
16
+ import { submissionContract, newerCommitChoice, resolveCategory, resolveTags, tagSlug } from "./form.mjs"
17
+ import { idUniverse, checkIdentity, listingOf, baselineFigures, figure, liveRegistry, registrySourceDetail, catalogPresentation, defaultPresentation } from "./registry.mjs"
18
18
  import { readTree } from "./tree.mjs"
19
19
  import { inspectTree } from "./plugin.mjs"
20
20
  import { findAgentControl, REMEDY as AGENT_CONTROL_REMEDY } from "./agent-control.mjs"
@@ -23,26 +23,16 @@ import { renderIssue, verifyAgainstOfficialParser } from "./issue.mjs"
23
23
  import { defaultBranchHead } from "./github.mjs"
24
24
  import { REFRESH_ACTION } from "./watch.mjs"
25
25
  import { omakitCacheDir } from "./paths.mjs"
26
- import { join } from "node:path"
27
- import { pathToFileURL } from "node:url"
28
-
29
- /**
30
- * The marketplace's own name for the verification action that lists a newer
31
- * commit of an already listed plugin, read from the pin rather than typed:
32
- * a form option retyped here would drift by a word, and a person would be
33
- * sent to choose something the form no longer offers.
34
- */
35
- async function newerCommitAction(pinDir) {
36
- const verification = await import(pathToFileURL(join(pinDir, "scripts/plugin-verification-request.mjs")).href)
37
- return verification.upstreamUpdateVerificationAction
38
- }
39
26
 
40
27
  /**
41
28
  * One arrow per cause, in this order, so a person fixes the thing that is
42
29
  * actually wrong. Measured before this: an id listed by its own repository
43
30
  * was told to "choose an unused plugin id outside the reserved namespace",
44
31
  * which is the remedy for a different failure, and an author who followed
45
- * it would have renamed a plugin the marketplace already lists.
32
+ * it would have renamed a plugin the marketplace already lists. The last
33
+ * arrow is for a listed repository whose manifest carries another id, or
34
+ * one with a second cause beside it; a plugin listed by its own repository
35
+ * with nothing else wrong is not a failure at all (see `listing` below).
46
36
  */
47
37
  function identityRemedies(identity, universe, newerCommit) {
48
38
  const remedies = []
@@ -241,9 +231,30 @@ export async function submitPreflight(options) {
241
231
  // --- identity -------------------------------------------------------------
242
232
 
243
233
  const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
244
- const identityRemedy = identity.ok ? null : identityRemedies(identity, universe, await newerCommitAction(pinDir))
234
+ const update = await newerCommitChoice({ pinDir })
235
+ const identityRemedy = identity.ok || identity.own ? null : identityRemedies(identity, universe, update.choice)
245
236
  const listed = identity.problems.some((problem) => problem.code === "plugin-id-listed" || problem.code === "submission-repository-listed")
246
237
 
238
+ // The subject's own listing is the third outcome of a run, not a failed
239
+ // check. Measured on 0.1.6: `omakit submit` on the author's own listed
240
+ // plugin printed FAIL identity.available and REFUSED, closed with "Fix it,
241
+ // then run submit again", and the remedy under it said there was nothing to
242
+ // submit. A healthy state was drawn as a failure and the closing line
243
+ // contradicted the remedy. Now the check passes with what the marketplace
244
+ // records about the listing, and the run ends in LISTED.
245
+ const listing = identity.own
246
+ ? (() => {
247
+ const record = listingOf(live, tree.pluginId) || { repository: subject.repository.url, id: tree.pluginId, addedAt: null, verificationCommit: null, verificationStatus: null, verificationCheckedAt: null }
248
+ return {
249
+ ...record,
250
+ localCommit: subject.commit,
251
+ sameCommit: Boolean(record.verificationCommit) && record.verificationCommit === subject.commit.toLowerCase(),
252
+ source: live.source,
253
+ updateRoute: { form: update.name, choice: update.choice },
254
+ }
255
+ })()
256
+ : null
257
+
247
258
  // The category and the tags are decided here, after the registry. Measured
248
259
  // before this: a listed plugin was asked for both and then told there was
249
260
  // nothing to submit. A listed plugin needs neither; an unlisted plugin with
@@ -268,11 +279,13 @@ export async function submitPreflight(options) {
268
279
  }
269
280
  checks.push(check("identity.available", {
270
281
  source: "marketplace-pin",
271
- why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${figure(universe.counts.listedIds)} listed ids, ${figure(universe.counts.retiredIds)} retired ids and ${figure(universe.counts.listedRepositories)} listed repositories from the registry and catalog at the commit the detail names, and the reserved namespace from the pinned catalog builder. The registry is read from the marketplace's current HEAD when the network is there because the pin's copy is stale within hours: registry.json changed in 4,201 of the marketplace's 4,293 commits in the 30 days to 2026-09-13, about 140 a day (docs/MEASUREMENTS.md M7). Code and the form are only ever read from the pin.`,
272
- verdict: identity.ok,
282
+ why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${figure(universe.counts.listedIds)} listed ids, ${figure(universe.counts.retiredIds)} retired ids and ${figure(universe.counts.listedRepositories)} listed repositories from the registry and catalog at the commit the detail names, and the reserved namespace from the pinned catalog builder. The registry is read from the marketplace's current HEAD when the network is there because the pin's copy is stale within hours: registry.json changed in 4,201 of the marketplace's 4,293 commits in the 30 days to 2026-09-13, about 140 a day (docs/MEASUREMENTS.md M7). Code and the form are only ever read from the pin. A plugin listed by its own repository (owner and name, case-insensitively, a trailing .git ignored) is not refused: it is listed, and this check says since when and at which commit.`,
283
+ verdict: identity.ok || identity.own,
273
284
  detail: `${identity.ok
274
285
  ? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
275
- : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
286
+ : listing
287
+ ? `listed by this repository since ${listing.addedAt || "an unrecorded date"}, verification commit ${listing.verificationCommit || "unrecorded"} (${listing.verificationStatus || "status unrecorded"}, checked ${listing.verificationCheckedAt || "at an unrecorded time"})`
288
+ : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
276
289
  remedy: identityRemedy,
277
290
  }))
278
291
 
@@ -295,7 +308,11 @@ export async function submitPreflight(options) {
295
308
  remedy: "Give the plugin a name in manifest.json, or pass --name.",
296
309
  }))
297
310
 
298
- checks.push(check("submission.category", {
311
+ // On the subject's own listing no body is rendered, on purpose: the
312
+ // submission form is not the route. The five checks that exist only for the
313
+ // body are omitted rather than drawn as questions waiting on identity,
314
+ // because identity did not fail.
315
+ if (!listing) checks.push(check("submission.category", {
299
316
  source: "marketplace-pin",
300
317
  why: `Exactly one category from the form's controlled list; the marketplace refuses \`submission-category-invalid\` otherwise. The list (${contract.categories.length} options) is read from ${contract.formPath} at the pin.`,
301
318
  verdict: category.ok,
@@ -304,7 +321,7 @@ export async function submitPreflight(options) {
304
321
  waitedOn: mootWaitsOn,
305
322
  }))
306
323
 
307
- checks.push(check("submission.tags", {
324
+ if (!listing) checks.push(check("submission.tags", {
308
325
  source: "marketplace-pin",
309
326
  why: `1 to ${contract.maximumTags} tags from the form's controlled list; the marketplace refuses \`submission-tag-count-invalid\` and \`submission-tags-invalid\` otherwise. The list (${contract.tagLabels.length} options) and the maximum are read from the pin.`,
310
327
  verdict: tags.ok,
@@ -323,7 +340,7 @@ export async function submitPreflight(options) {
323
340
  ].filter(Boolean)
324
341
  let issue = null
325
342
  let parsed = null
326
- if (!bodyWaitsOn.length) {
343
+ if (!bodyWaitsOn.length && !listing) {
327
344
  issue = renderIssue(contract, {
328
345
  pluginName,
329
346
  repositoryUrl: subject.repository.url,
@@ -343,7 +360,7 @@ export async function submitPreflight(options) {
343
360
  remedy: subject.repository.url ? null : "Give the repository a github.com origin remote.",
344
361
  }))
345
362
 
346
- checks.push(check("submission.headings", {
363
+ if (!listing) checks.push(check("submission.headings", {
347
364
  source: "marketplace-pin",
348
365
  why: `The six form headings must appear in exact order: ${contract.headings.join(", ")}. 11 open submissions are malformed in the body and receive "The validation result could not be published to the issue. A maintainer must review the workflow.", which blames the maintainer for the author's mistake; one of them differs from a valid submission by the single word "Suggested" instead of "Suggest". The headings are rendered from the form at the pin, never typed.`,
349
366
  verdict: Boolean(issue),
@@ -351,7 +368,7 @@ export async function submitPreflight(options) {
351
368
  waitedOn: bodyWaitsOn,
352
369
  }))
353
370
 
354
- checks.push(check("submission.checklist", {
371
+ if (!listing) checks.push(check("submission.checklist", {
355
372
  source: "marketplace-pin",
356
373
  why: `All ${contract.checklist.length} checklist items must be present with their exact text and checked; the marketplace refuses \`submission-checklist-unconfirmed\` otherwise. The text is read from the form at the pin, character for character.`,
357
374
  verdict: Boolean(issue),
@@ -359,7 +376,7 @@ export async function submitPreflight(options) {
359
376
  waitedOn: bodyWaitsOn,
360
377
  }))
361
378
 
362
- checks.push(check("submission.official-parser", {
379
+ if (!listing) checks.push(check("submission.official-parser", {
363
380
  source: "marketplace-pin",
364
381
  why: "The strongest available proof that the body is well formed: the marketplace's own `parseCurrentSubmission` from the pinned commit is run over the rendered title and body. If it accepts them here it accepts them there, and 0 of the marketplace's heading, tag and checklist rules are duplicated in Omakit, so none of them can drift. This is the check that closes all 50 measured title-and-body failures at once: 39 on the title prefix plus 11 malformed bodies.",
365
382
  verdict: Boolean(parsed?.ok),
@@ -437,7 +454,12 @@ export async function submitPreflight(options) {
437
454
  const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
438
455
  const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
439
456
  const unknown = checks.filter((entry) => entry.verdict === "unknown")
440
- const ready = blocking.length === 0
457
+ // Three outcomes. `refused`: a blocking check failed and no body exists.
458
+ // `listed`: nothing failed and the plugin is already listed by this
459
+ // repository, so there is no body either, and nothing is wrong. `ready`:
460
+ // the body. `ready` the boolean stays what it was, true for the third only.
461
+ const outcome = blocking.length ? "refused" : listing ? "listed" : "ready"
462
+ const ready = outcome === "ready"
441
463
 
442
464
  return {
443
465
  pin: {
@@ -478,7 +500,9 @@ export async function submitPreflight(options) {
478
500
  offline: options.offline === true,
479
501
  }),
480
502
  checks,
503
+ outcome,
481
504
  ready,
505
+ listing,
482
506
  blocking: blocking.map((entry) => entry.id),
483
507
  advisory: advisory.map((entry) => entry.id),
484
508
  unknown: unknown.map((entry) => entry.id),
@@ -45,10 +45,13 @@ export const COMMANDS = Object.freeze([
45
45
  lines: [
46
46
  "Every check that is knowable before submitting, the resolved commit, and",
47
47
  "the exact issue title and body. Prints them. Never posts anything.",
48
- "A listed plugin is refused at identity and needs no category or tags.",
49
- "An unlisted one without them is asked at a terminal, with the form's",
50
- "lists numbered; in a pipe or with --json that is a usage error, exit 2.",
51
- "The report ends with the command line that repeats the run unasked.",
48
+ "Three outcomes: READY (exit 0, the body), REFUSED (exit 1, no body), and",
49
+ "LISTED (exit 0): the plugin is already listed by its own repository, so",
50
+ "the submission form is not the route and nothing is asked. An id taken",
51
+ "by another repository is refused. An unlisted plugin without a category",
52
+ "and tags is asked at a terminal, with the form's lists numbered; in a",
53
+ "pipe or with --json that is a usage error, exit 2. A READY or REFUSED",
54
+ "report ends with the command line that repeats the run unasked.",
52
55
  ],
53
56
  },
54
57
  {