dunstan 0.1.14 → 0.1.16

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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/dist/dunstan.mjs +344 -130
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,7 +26,7 @@ jobs:
26
26
  handback:
27
27
  runs-on: ubuntu-latest
28
28
  steps:
29
- - uses: BargLabs/dunstan@v0.1.14 # pin the full commit SHA in production
29
+ - uses: BargLabs/dunstan@v0.1.16 # pin the full commit SHA in production
30
30
  with:
31
31
  report-source: pr-body
32
32
  ```
@@ -52,7 +52,7 @@ export exists).
52
52
 
53
53
  ## Use with Claude
54
54
 
55
- Dunstan ships as a Claude Code plugin: two MCP tools and a skill that has Claude declare, check and
55
+ Dunstan ships as a Claude Code plugin: three MCP tools and a skill that has Claude declare, check and
56
56
  report a pull request's handback before it says done ([`plugins/dunstan/`](plugins/dunstan/)).
57
57
 
58
58
  **Install.** In Claude Code:
@@ -62,7 +62,7 @@ report a pull request's handback before it says done ([`plugins/dunstan/`](plugi
62
62
  /plugin install dunstan@barglabs
63
63
  ```
64
64
 
65
- The plugin runs the MCP server with `npx -y dunstan@0.1.14 mcp`, the `dunstan` npm package at that
65
+ The plugin runs the MCP server with `npx -y dunstan@0.1.16 mcp`, the `dunstan` npm package at that
66
66
  exact version: one self-contained file, no dependencies.
67
67
 
68
68
  **The token.** When you enable the plugin, Claude Code asks for an optional GitHub token and keeps it
package/dist/dunstan.mjs CHANGED
@@ -9881,7 +9881,7 @@ async function reportFromComment(client, repository, number4, login) {
9881
9881
  // src/record/checker.ts
9882
9882
  import { readFileSync } from "node:fs";
9883
9883
  var CHECKER_NAME = "dunstan";
9884
- var CHECKER_VERSION = "0.1.14";
9884
+ var CHECKER_VERSION = "0.1.16";
9885
9885
  function checkerIdentity(artifact) {
9886
9886
  return {
9887
9887
  name: CHECKER_NAME,
@@ -10567,7 +10567,7 @@ var FILES = { "handback-block-0.1.schema.json": `{
10567
10567
  }
10568
10568
  }
10569
10569
  }
10570
- `, "survey-0.1-draft.schema.json": '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "$id": "https://barglabs.ai/dunstan/spec/schema/survey-0.1-draft.schema.json",\n "title": "Dunstan survey record, DRAFT 0.1",\n "description": "DRAFT, informative, for the operator to check (spec/claim-format.md, \\"DRAFT survey 0.1\\"). A read-only survey of a repository\'s recent pull requests: the selection, one entry per selected pull request with the record a check wrote for it or the reason there is none, and an aggregate that is a pure function of the entries. Each embedded record is a 0.1 or DRAFT 0.2.0 record and is validated against its own schema when the survey is verified. It changes neither record schema.",\n "type": "object",\n "required": ["format", "checker", "selection", "entries", "aggregate", "rerun", "digest"],\n "additionalProperties": false,\n "properties": {\n "format": { "const": "dunstan-survey-0.1-draft" },\n "checker": { "$ref": "record-0.1.schema.json#/$defs/predicate/properties/checker" },\n "selection": {\n "type": "object",\n "required": ["parameters", "reader", "listing", "selected"],\n "additionalProperties": false,\n "properties": {\n "parameters": { "$ref": "#/$defs/parameters" },\n "reader": {\n "description": "Whether the survey read GitHub with a token (GITHUB_TOKEN was set). The aggregate\'s remedy lines are worded for it. The token is never recorded.",\n "type": "object",\n "required": ["authenticated"],\n "additionalProperties": false,\n "properties": { "authenticated": { "type": "boolean" } }\n },\n "listing": { "$ref": "#/$defs/listing" },\n "selected": {\n "type": "array",\n "uniqueItems": true,\n "items": { "$ref": "#/$defs/number" }\n }\n }\n },\n "entries": {\n "description": "One per selected pull request, in selection order. Verify checks that their pull request numbers are exactly `selection.selected`, which holds no number twice.",\n "type": "array",\n "uniqueItems": true,\n "items": { "$ref": "#/$defs/entry" }\n },\n "aggregate": { "$ref": "#/$defs/aggregate" },\n "rerun": {\n "type": "object",\n "required": ["offline", "online"],\n "additionalProperties": false,\n "properties": {\n "offline": { "type": "string", "minLength": 1 },\n "online": { "type": "string", "minLength": 1 }\n }\n },\n "digest": {\n "description": "SHA-256 of the JCS bytes of the survey record without this member.",\n "type": "object",\n "required": ["sha256"],\n "additionalProperties": false,\n "properties": { "sha256": { "$ref": "record-0.1.schema.json#/$defs/sha256" } }\n }\n },\n "$defs": {\n "number": { "type": "integer", "minimum": 1 },\n "count": { "type": "integer", "minimum": 0 },\n "login": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\\\\[bot\\\\])?$" },\n "parameters": {\n "type": "object",\n "required": ["repository", "last", "since", "authors", "report", "advisory"],\n "additionalProperties": false,\n "properties": {\n "repository": { "$ref": "record-0.1.schema.json#/$defs/repository" },\n "last": { "type": "integer", "minimum": 1, "maximum": 200 },\n "since": {\n "oneOf": [\n { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" },\n { "type": "null" }\n ]\n },\n "authors": { "type": "array", "items": { "$ref": "#/$defs/login" } },\n "report": {\n "oneOf": [\n {\n "type": "object",\n "required": ["kind"],\n "additionalProperties": false,\n "properties": { "kind": { "const": "pr-body" } }\n },\n {\n "type": "object",\n "required": ["kind", "author"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "pr-comment" },\n "author": { "$ref": "#/$defs/login" }\n }\n }\n ]\n },\n "advisory": { "type": "boolean" }\n }\n },\n "listing": {\n "type": "object",\n "required": ["pageBound", "pages", "pullRequests", "duplicates", "end"],\n "additionalProperties": false,\n "properties": {\n "pageBound": { "type": "integer", "minimum": 1 },\n "pages": {\n "type": "array",\n "minItems": 1,\n "items": {\n "type": "object",\n "required": ["page", "locator", "sha256", "readAt", "items", "duplicates"],\n "additionalProperties": false,\n "properties": {\n "page": { "$ref": "#/$defs/number" },\n "locator": { "type": "string", "minLength": 1 },\n "sha256": { "$ref": "record-0.1.schema.json#/$defs/sha256" },\n "readAt": { "$ref": "record-0.1.schema.json#/$defs/timestamp" },\n "items": { "$ref": "#/$defs/count" },\n "duplicates": {\n "description": "How many of this page\'s items repeat a pull request number an earlier item of the listing already held. They are not in `pullRequests`.",\n "$ref": "#/$defs/count"\n },\n "error": { "type": "string", "pattern": "^(?:http_[1-5][0-9]{2}|network|parse)$" }\n }\n }\n },\n "duplicates": {\n "description": "How many listed items repeated an earlier one: the sum of the pages\' duplicates. The pages\' items are the length of `pullRequests` plus this.",\n "$ref": "#/$defs/count"\n },\n "pullRequests": {\n "description": "Each listed pull request once, at its first occurrence, in listing order. Verify refuses a number listed twice.",\n "type": "array",\n "uniqueItems": true,\n "items": {\n "type": "object",\n "required": ["number", "createdAt", "author", "merged"],\n "additionalProperties": false,\n "properties": {\n "number": { "$ref": "#/$defs/number" },\n "createdAt": { "$ref": "record-0.1.schema.json#/$defs/timestamp" },\n "author": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },\n "merged": { "type": "boolean" }\n }\n }\n },\n "end": {\n "enum": [\n "selection_full",\n "since_reached",\n "listing_ended",\n "listing_unreadable",\n "page_bound"\n ]\n }\n }\n },\n "noRecordReason": {\n "enum": [\n "pull_request_unreadable",\n "pull_request_moved",\n "rate_limited",\n "report_unavailable"\n ]\n },\n "entry": {\n "oneOf": [\n {\n "type": "object",\n "required": ["pullRequest", "record"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": { "$ref": "#/$defs/number" },\n "record": {\n "description": "The record `dunstan check` writes for this pull request, as written: a 0.1 record, or a DRAFT 0.2.0 record when the survey ran the advisory reader. Verify validates it against its own schema.",\n "type": "object"\n }\n }\n },\n {\n "type": "object",\n "required": ["pullRequest", "record", "reason"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": { "$ref": "#/$defs/number" },\n "record": { "type": "null" },\n "reason": { "$ref": "#/$defs/noRecordReason" }\n }\n }\n ]\n },\n "columns": {\n "type": "object",\n "required": ["supported", "unverifiable", "notRecordCheckable", "contradicted"],\n "properties": {\n "supported": { "$ref": "#/$defs/count" },\n "unverifiable": { "$ref": "#/$defs/count" },\n "notRecordCheckable": { "$ref": "#/$defs/count" },\n "contradicted": { "$ref": "#/$defs/count" }\n }\n },\n "fixed": { "type": "string", "pattern": "^[01]\\\\.[0-9]{4}$" },\n "rate": {\n "type": "object",\n "required": ["numerator", "denominator", "value", "wilson95"],\n "additionalProperties": false,\n "properties": {\n "numerator": { "$ref": "#/$defs/count" },\n "denominator": { "$ref": "#/$defs/count" },\n "value": { "oneOf": [{ "$ref": "#/$defs/fixed" }, { "type": "null" }] },\n "wilson95": {\n "oneOf": [\n {\n "type": "object",\n "required": ["low", "high"],\n "additionalProperties": false,\n "properties": {\n "low": { "$ref": "#/$defs/fixed" },\n "high": { "$ref": "#/$defs/fixed" }\n }\n },\n { "type": "null" }\n ]\n }\n }\n },\n "aggregate": {\n "type": "object",\n "required": [\n "mapping",\n "pullRequests",\n "claims",\n "byType",\n "cannotSettle",\n "contradictedOfSettled",\n "unsettled"\n ],\n "additionalProperties": false,\n "properties": {\n "mapping": {\n "type": "object",\n "required": ["version"],\n "additionalProperties": false,\n "properties": { "version": { "type": "string", "minLength": 1 } }\n },\n "pullRequests": {\n "type": "object",\n "required": [\n "listed",\n "selected",\n "withRecord",\n "blockFound",\n "blockMissing",\n "blockInvalid",\n "blockAmbiguous",\n "noRecord"\n ],\n "additionalProperties": false,\n "properties": {\n "listed": { "$ref": "#/$defs/count" },\n "selected": { "$ref": "#/$defs/count" },\n "withRecord": { "$ref": "#/$defs/count" },\n "blockFound": { "$ref": "#/$defs/count" },\n "blockMissing": { "$ref": "#/$defs/count" },\n "blockInvalid": { "$ref": "#/$defs/count" },\n "blockAmbiguous": { "$ref": "#/$defs/count" },\n "noRecord": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["reason", "count"],\n "additionalProperties": false,\n "properties": {\n "reason": { "$ref": "#/$defs/noRecordReason" },\n "count": { "$ref": "#/$defs/count" }\n }\n }\n }\n }\n },\n "claims": {\n "allOf": [{ "$ref": "#/$defs/columns" }],\n "required": ["total"],\n "properties": { "total": { "$ref": "#/$defs/count" } },\n "unevaluatedProperties": false\n },\n "byType": {\n "type": "array",\n "items": {\n "allOf": [{ "$ref": "#/$defs/columns" }],\n "required": ["type"],\n "properties": {\n "type": { "enum": ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "R"] }\n },\n "unevaluatedProperties": false\n }\n },\n "cannotSettle": { "$ref": "#/$defs/rate" },\n "contradictedOfSettled": { "$ref": "#/$defs/rate" },\n "unsettled": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["reason", "column", "count", "remedy"],\n "additionalProperties": false,\n "properties": {\n "reason": { "$ref": "record-0.1.schema.json#/$defs/claimReason" },\n "column": { "enum": ["unverifiable", "notRecordCheckable"] },\n "count": { "type": "integer", "minimum": 1 },\n "remedy": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }\n }\n }\n },\n "advisory": {\n "type": "object",\n "required": ["pullRequests", "notes"],\n "additionalProperties": false,\n "properties": {\n "pullRequests": { "$ref": "#/$defs/count" },\n "notes": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["note", "count"],\n "additionalProperties": false,\n "properties": {\n "note": { "type": "string", "minLength": 1 },\n "count": { "type": "integer", "minimum": 1 }\n }\n }\n }\n }\n }\n }\n }\n }\n}\n' };
10570
+ `, "survey-0.1-draft.schema.json": '{\n "$schema": "https://json-schema.org/draft/2020-12/schema",\n "$id": "https://barglabs.ai/dunstan/spec/schema/survey-0.1-draft.schema.json",\n "title": "Dunstan survey record, DRAFT 0.1",\n "description": "DRAFT, informative, for the operator to check (spec/claim-format.md, \\"DRAFT survey 0.1\\"). A read-only survey of a repository\'s recent pull requests: the selection, one entry per selected pull request with the record a check wrote for it or the reason there is none, and an aggregate that is a pure function of the entries. Each embedded record is a 0.1 or DRAFT 0.2.0 record and is validated against its own schema when the survey is verified. It changes neither record schema.",\n "type": "object",\n "required": ["format", "checker", "selection", "entries", "aggregate", "rerun", "digest"],\n "additionalProperties": false,\n "properties": {\n "format": { "const": "dunstan-survey-0.1-draft" },\n "checker": { "$ref": "record-0.1.schema.json#/$defs/predicate/properties/checker" },\n "selection": {\n "type": "object",\n "required": ["parameters", "reader", "listing", "selected"],\n "additionalProperties": false,\n "properties": {\n "parameters": { "$ref": "#/$defs/parameters" },\n "reader": {\n "description": "Whether the survey read GitHub with a token (GITHUB_TOKEN was set). The aggregate\'s remedy lines are worded for it. The token is never recorded.",\n "type": "object",\n "required": ["authenticated"],\n "additionalProperties": false,\n "properties": { "authenticated": { "type": "boolean" } }\n },\n "listing": { "$ref": "#/$defs/listing" },\n "selected": {\n "type": "array",\n "uniqueItems": true,\n "items": { "$ref": "#/$defs/number" }\n }\n }\n },\n "entries": {\n "description": "One per selected pull request, in selection order. Verify checks that their pull request numbers are exactly `selection.selected`, which holds no number twice.",\n "type": "array",\n "uniqueItems": true,\n "items": { "$ref": "#/$defs/entry" }\n },\n "aggregate": { "$ref": "#/$defs/aggregate" },\n "rerun": {\n "type": "object",\n "required": ["offline", "online"],\n "additionalProperties": false,\n "properties": {\n "offline": { "type": "string", "minLength": 1 },\n "online": { "type": "string", "minLength": 1 }\n }\n },\n "digest": {\n "description": "SHA-256 of the JCS bytes of the survey record without this member.",\n "type": "object",\n "required": ["sha256"],\n "additionalProperties": false,\n "properties": { "sha256": { "$ref": "record-0.1.schema.json#/$defs/sha256" } }\n }\n },\n "$defs": {\n "number": { "type": "integer", "minimum": 1 },\n "count": { "type": "integer", "minimum": 0 },\n "login": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\\\\[bot\\\\])?$" },\n "parameters": {\n "type": "object",\n "required": ["repository", "last", "since", "authors", "report", "advisory"],\n "additionalProperties": false,\n "properties": {\n "repository": { "$ref": "record-0.1.schema.json#/$defs/repository" },\n "last": { "type": "integer", "minimum": 1, "maximum": 200 },\n "since": {\n "oneOf": [\n { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" },\n { "type": "null" }\n ]\n },\n "authors": { "type": "array", "items": { "$ref": "#/$defs/login" } },\n "report": {\n "oneOf": [\n {\n "type": "object",\n "required": ["kind"],\n "additionalProperties": false,\n "properties": { "kind": { "const": "pr-body" } }\n },\n {\n "type": "object",\n "required": ["kind", "author"],\n "additionalProperties": false,\n "properties": {\n "kind": { "const": "pr-comment" },\n "author": { "$ref": "#/$defs/login" }\n }\n }\n ]\n },\n "advisory": { "type": "boolean" }\n }\n },\n "listingPage": {\n "type": "object",\n "required": ["page", "locator", "sha256", "readAt", "items", "duplicates"],\n "additionalProperties": false,\n "properties": {\n "page": { "$ref": "#/$defs/number" },\n "locator": { "type": "string", "minLength": 1 },\n "sha256": { "$ref": "record-0.1.schema.json#/$defs/sha256" },\n "readAt": { "$ref": "record-0.1.schema.json#/$defs/timestamp" },\n "items": { "$ref": "#/$defs/count" },\n "duplicates": {\n "description": "How many of this page\'s items repeat a pull request number an earlier item of the same read already held. In the read used, they are not in `pullRequests`.",\n "$ref": "#/$defs/count"\n },\n "error": { "type": "string", "pattern": "^(?:http_[1-5][0-9]{2}|network|parse)$" }\n }\n },\n "listing": {\n "description": "The read of the listing the selection is from, the last one, and the reads abandoned before it. A read is abandoned at its first page that repeats an earlier item, unless it is the third read, which is never abandoned.",\n "type": "object",\n "required": ["pageBound", "pages", "abandoned", "pullRequests", "duplicates", "end"],\n "additionalProperties": false,\n "properties": {\n "pageBound": { "type": "integer", "minimum": 1 },\n "pages": {\n "type": "array",\n "minItems": 1,\n "items": { "$ref": "#/$defs/listingPage" }\n },\n "abandoned": {\n "description": "One item per abandoned read, in order: its pages, the last of them the page that repeated an earlier item. No pull request list; abandoned reads contribute nothing to the selection. Empty when the first read was used.",\n "type": "array",\n "maxItems": 2,\n "items": {\n "type": "object",\n "required": ["pages"],\n "additionalProperties": false,\n "properties": {\n "pages": {\n "type": "array",\n "minItems": 1,\n "items": { "$ref": "#/$defs/listingPage" }\n }\n }\n }\n },\n "duplicates": {\n "description": "How many listed items repeated an earlier one: the sum of the pages\' duplicates. The pages\' items are the length of `pullRequests` plus this.",\n "$ref": "#/$defs/count"\n },\n "pullRequests": {\n "description": "Each listed pull request once, at its first occurrence, in listing order. Verify refuses a number listed twice.",\n "type": "array",\n "uniqueItems": true,\n "items": {\n "type": "object",\n "required": ["number", "createdAt", "author", "merged"],\n "additionalProperties": false,\n "properties": {\n "number": { "$ref": "#/$defs/number" },\n "createdAt": { "$ref": "record-0.1.schema.json#/$defs/timestamp" },\n "author": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] },\n "merged": { "type": "boolean" }\n }\n }\n },\n "end": {\n "enum": [\n "selection_full",\n "since_reached",\n "listing_ended",\n "listing_unreadable",\n "page_bound"\n ]\n }\n }\n },\n "noRecordReason": {\n "enum": [\n "pull_request_unreadable",\n "pull_request_moved",\n "rate_limited",\n "report_unavailable"\n ]\n },\n "entry": {\n "oneOf": [\n {\n "type": "object",\n "required": ["pullRequest", "record"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": { "$ref": "#/$defs/number" },\n "record": {\n "description": "The record `dunstan check` writes for this pull request, as written: a 0.1 record, or a DRAFT 0.2.0 record when the survey ran the advisory reader. Verify validates it against its own schema.",\n "type": "object"\n }\n }\n },\n {\n "type": "object",\n "required": ["pullRequest", "record", "reason"],\n "additionalProperties": false,\n "properties": {\n "pullRequest": { "$ref": "#/$defs/number" },\n "record": { "type": "null" },\n "reason": { "$ref": "#/$defs/noRecordReason" }\n }\n }\n ]\n },\n "columns": {\n "type": "object",\n "required": ["supported", "unverifiable", "notRecordCheckable", "contradicted"],\n "properties": {\n "supported": { "$ref": "#/$defs/count" },\n "unverifiable": { "$ref": "#/$defs/count" },\n "notRecordCheckable": { "$ref": "#/$defs/count" },\n "contradicted": { "$ref": "#/$defs/count" }\n }\n },\n "fixed": { "type": "string", "pattern": "^[01]\\\\.[0-9]{4}$" },\n "rate": {\n "type": "object",\n "required": ["numerator", "denominator", "value", "wilson95"],\n "additionalProperties": false,\n "properties": {\n "numerator": { "$ref": "#/$defs/count" },\n "denominator": { "$ref": "#/$defs/count" },\n "value": { "oneOf": [{ "$ref": "#/$defs/fixed" }, { "type": "null" }] },\n "wilson95": {\n "oneOf": [\n {\n "type": "object",\n "required": ["low", "high"],\n "additionalProperties": false,\n "properties": {\n "low": { "$ref": "#/$defs/fixed" },\n "high": { "$ref": "#/$defs/fixed" }\n }\n },\n { "type": "null" }\n ]\n }\n }\n },\n "aggregate": {\n "type": "object",\n "required": [\n "mapping",\n "pullRequests",\n "claims",\n "byType",\n "cannotSettle",\n "contradictedOfSettled",\n "unsettled"\n ],\n "additionalProperties": false,\n "properties": {\n "mapping": {\n "type": "object",\n "required": ["version"],\n "additionalProperties": false,\n "properties": { "version": { "type": "string", "minLength": 1 } }\n },\n "pullRequests": {\n "type": "object",\n "required": [\n "listed",\n "selected",\n "withRecord",\n "blockFound",\n "blockMissing",\n "blockInvalid",\n "blockAmbiguous",\n "noRecord"\n ],\n "additionalProperties": false,\n "properties": {\n "listed": { "$ref": "#/$defs/count" },\n "selected": { "$ref": "#/$defs/count" },\n "withRecord": { "$ref": "#/$defs/count" },\n "blockFound": { "$ref": "#/$defs/count" },\n "blockMissing": { "$ref": "#/$defs/count" },\n "blockInvalid": { "$ref": "#/$defs/count" },\n "blockAmbiguous": { "$ref": "#/$defs/count" },\n "noRecord": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["reason", "count"],\n "additionalProperties": false,\n "properties": {\n "reason": { "$ref": "#/$defs/noRecordReason" },\n "count": { "$ref": "#/$defs/count" }\n }\n }\n }\n }\n },\n "claims": {\n "allOf": [{ "$ref": "#/$defs/columns" }],\n "required": ["total"],\n "properties": { "total": { "$ref": "#/$defs/count" } },\n "unevaluatedProperties": false\n },\n "byType": {\n "type": "array",\n "items": {\n "allOf": [{ "$ref": "#/$defs/columns" }],\n "required": ["type"],\n "properties": {\n "type": { "enum": ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "R"] }\n },\n "unevaluatedProperties": false\n }\n },\n "cannotSettle": { "$ref": "#/$defs/rate" },\n "contradictedOfSettled": { "$ref": "#/$defs/rate" },\n "unsettled": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["reason", "column", "count", "remedy"],\n "additionalProperties": false,\n "properties": {\n "reason": { "$ref": "record-0.1.schema.json#/$defs/claimReason" },\n "column": { "enum": ["unverifiable", "notRecordCheckable"] },\n "count": { "type": "integer", "minimum": 1 },\n "remedy": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }\n }\n }\n },\n "advisory": {\n "type": "object",\n "required": ["pullRequests", "notes"],\n "additionalProperties": false,\n "properties": {\n "pullRequests": { "$ref": "#/$defs/count" },\n "notes": {\n "type": "array",\n "items": {\n "type": "object",\n "required": ["note", "count"],\n "additionalProperties": false,\n "properties": {\n "note": { "type": "string", "minLength": 1 },\n "count": { "type": "integer", "minimum": 1 }\n }\n }\n }\n }\n }\n }\n }\n }\n}\n' };
10571
10571
  function schemaText(file2) {
10572
10572
  const text2 = FILES[file2];
10573
10573
  if (text2 === void 0) throw new Error(`no schema ${file2} in this build`);
@@ -35146,7 +35146,7 @@ var StdioServerTransport = class {
35146
35146
 
35147
35147
  // src/mcp/server.ts
35148
35148
  import { existsSync, writeFileSync as writeFileSync3 } from "node:fs";
35149
- import { basename as basename2, resolve as resolve3 } from "node:path";
35149
+ import { basename as basename2, isAbsolute, resolve as resolve3 } from "node:path";
35150
35150
 
35151
35151
  // node_modules/.pnpm/zod@4.6.5/node_modules/zod/v3/helpers/util.js
35152
35152
  var util;
@@ -43335,14 +43335,147 @@ function suggestText(report) {
43335
43335
  return renderSuggestions(suggestDeclarations(report));
43336
43336
  }
43337
43337
 
43338
+ // src/template/tests-ci.ts
43339
+ var plural2 = (n, one, many) => `${n} ${n === 1 ? one : many}`;
43340
+ function unsettled(reason, why, remedy) {
43341
+ const notes = [`tests are not declared: ${why} (${reason}).`];
43342
+ if (remedy !== void 0) notes.push(`remedy: ${remedy}`);
43343
+ return { status: "unsettled", reason, notes };
43344
+ }
43345
+ async function testsFromCi(client, input2) {
43346
+ const { repository, headCommit: head, record: record2 } = input2;
43347
+ if (!client.hasToken) {
43348
+ return unsettled(
43349
+ "no_token",
43350
+ "GITHUB_TOKEN is not set, and GitHub answers an artifact download only with a token, even on a public repository",
43351
+ "set GITHUB_TOKEN to a token that can read the repository, with actions: read, and run dunstan template --tests-ci again."
43352
+ );
43353
+ }
43354
+ if (!input2.pushed) {
43355
+ return unsettled(
43356
+ "not_pushed",
43357
+ `HEAD ${head} is not on any remote-tracking branch, so no CI run can have tested it`,
43358
+ "push it, wait for CI to finish, and run dunstan template --tests-ci again (run git fetch first if it was pushed from elsewhere)."
43359
+ );
43360
+ }
43361
+ const observed = { runsNotCompleted: 0 };
43362
+ const evidence = await readTestRecord(client, repository, record2, head, observed);
43363
+ const named = `the artifact ${record2.artifact} holding ${record2.path} from job ${record2.job}`;
43364
+ const pending = observed.runsNotCompleted > 0 ? `CI has not finished: ${plural2(observed.runsNotCompleted, "run", "runs")} of ${record2.workflow} at ${head} ${observed.runsNotCompleted === 1 ? "is" : "are"} not completed` : void 0;
43365
+ const again = "run dunstan template --tests-ci again once CI has finished on this commit.";
43366
+ if (evidence.status !== "ok") {
43367
+ const reason = evidence.status === "unreadable" ? `source_unreadable:${evidence.source}` : `evidence_field_unpopulated:${evidence.field}`;
43368
+ if (pending !== void 0) return unsettled(reason, pending, again);
43369
+ const why = reason === "source_unreadable:artifact" ? `a run of ${record2.workflow} at ${head} uploaded ${record2.artifact}, and it could not be read` : `the workflow runs of ${record2.workflow} at ${head} could not be read`;
43370
+ return unsettled(
43371
+ reason,
43372
+ why,
43373
+ remedyForReason(reason, { hasToken: true }) ?? "run dunstan template --tests-ci again."
43374
+ );
43375
+ }
43376
+ if (evidence.runs.length === 0) {
43377
+ if (pending !== void 0) {
43378
+ return unsettled(
43379
+ "record_not_found",
43380
+ `${pending}, and no completed run uploaded ${named}`,
43381
+ again
43382
+ );
43383
+ }
43384
+ return unsettled(
43385
+ "record_not_found",
43386
+ `no run of ${record2.workflow} at ${head} uploaded ${named}`,
43387
+ remedyForReason("record_not_found", { hasToken: true })
43388
+ );
43389
+ }
43390
+ const executed = new Set(evidence.runs.map((r) => r.executed));
43391
+ const failed = new Set(evidence.runs.map((r) => r.failed));
43392
+ if (executed.size > 1 || failed.size > 1) {
43393
+ const counts = evidence.runs.map((r) => `run ${r.runId}: ${r.executed} executed, ${r.failed} failed`).join("; ");
43394
+ return unsettled(
43395
+ "record_ambiguous",
43396
+ `the runs of ${record2.workflow} at ${head} that uploaded ${record2.artifact} disagree (${counts}), and the gate never picks one`,
43397
+ "make the runs agree (re-run the one that differs), or name a job only one of them runs."
43398
+ );
43399
+ }
43400
+ const run = evidence.runs[0];
43401
+ const runs = evidence.runs.map((r) => r.runId).join(", ");
43402
+ const notes = [
43403
+ `tests: ${run.executed} executed and ${run.failed} failed in ${record2.path} in the artifact ${record2.artifact} of job ${record2.job} in ${record2.workflow} at ${head}, read from CI as the gate reads it (${evidence.runs.length === 1 ? "run" : "runs"} ${runs}).`
43404
+ ];
43405
+ if (pending !== void 0) {
43406
+ notes.push(
43407
+ `${pending}; a run that finishes later with other counts makes the gate's read ambiguous. Run dunstan template --tests-ci again once CI has finished.`
43408
+ );
43409
+ }
43410
+ return {
43411
+ status: "settled",
43412
+ run: { command: input2.command, count: run.executed, failures: run.failed, record: record2 },
43413
+ notes
43414
+ };
43415
+ }
43416
+
43417
+ // src/template/run.ts
43418
+ var DEFAULT_ARTIFACT = "dunstan-junit";
43419
+ var DEFAULT_ARTIFACT_PATH = "junit.xml";
43420
+ async function testsSource(request, context) {
43421
+ const { cwd, tests } = request;
43422
+ if (tests === void 0) return void 0;
43423
+ if (tests.from === "local") return { kind: "local", junit: tests.junit, command: tests.command };
43424
+ if (tests.from === "junit") {
43425
+ return {
43426
+ kind: "junit",
43427
+ junit: tests.junit,
43428
+ command: tests.command,
43429
+ record: {
43430
+ workflow: tests.workflow,
43431
+ job: tests.job,
43432
+ artifact: tests.artifact,
43433
+ path: tests.artifactPath
43434
+ }
43435
+ };
43436
+ }
43437
+ const head = headCommit(cwd);
43438
+ const repository = tests.repository ?? originRepository(cwd);
43439
+ const client = new GitHubClient({
43440
+ token: context.env.GITHUB_TOKEN,
43441
+ ...context.fetch === void 0 ? {} : { fetch: context.fetch },
43442
+ warn: context.warn
43443
+ });
43444
+ const read = await testsFromCi(client, {
43445
+ repository,
43446
+ headCommit: head,
43447
+ command: tests.command,
43448
+ pushed: isPushed(cwd, head),
43449
+ record: {
43450
+ kind: "junit",
43451
+ workflow: tests.workflow,
43452
+ job: tests.job,
43453
+ artifact: tests.artifact ?? DEFAULT_ARTIFACT,
43454
+ path: tests.artifactPath ?? DEFAULT_ARTIFACT_PATH
43455
+ }
43456
+ });
43457
+ return { kind: "ci", read };
43458
+ }
43459
+ async function runTemplate(request, context) {
43460
+ const tests = await testsSource(request, context);
43461
+ const t = templateBlock({ cwd: request.cwd, base: request.base, tests });
43462
+ return {
43463
+ fence: renderBlock(t.block),
43464
+ notes: t.notes,
43465
+ tests: tests === void 0 ? "none" : tests.kind === "ci" && tests.read.status === "unsettled" ? "not_declared" : "declared"
43466
+ };
43467
+ }
43468
+
43338
43469
  // src/mcp/server.ts
43339
43470
  var TOOL_NAME = "dunstan_check_handback";
43340
43471
  var SUGGEST_TOOL_NAME = "dunstan_suggest_declarations";
43472
+ var TEMPLATE_TOOL_NAME = "dunstan_template_handback";
43341
43473
  var InputError = class extends Error {
43342
43474
  };
43343
- var INSTRUCTIONS = `Before you say a task is done: generate your dunstan-handback block with \`dunstan template\` and add your test and reference claims; call ${SUGGEST_TOOL_NAME} with your report and declare or reword each sentence it lists; then call ${TOOL_NAME} with your pull request and the block. If the verdict is not pass, report the rows as they are; never edit the block to make it pass without changing the work. The check reads only what the block declares; the prose is yours to bring into the block.`;
43475
+ var INSTRUCTIONS = `Before you say a task is done: call ${TEMPLATE_TOOL_NAME} with the absolute path of your checkout (and tests.from "ci" once CI has finished on your last push) to generate your dunstan-handback block, or run \`dunstan template\`, the same thing from a shell; add your reference claims; call ${SUGGEST_TOOL_NAME} with your report and declare or reword each sentence it lists; then call ${TOOL_NAME} with your pull request and the block. If the verdict is not pass, report the rows as they are; never edit the block to make it pass without changing the work. The check reads only what the block declares; the prose is yours to bring into the block.`;
43344
43476
  var SUGGEST_DESCRIPTION = `List the sentences in your report's prose that look like claims your dunstan-handback block does not declare, before you say done. Give report, the report text. For each, declare it in the block, or reword it if it is not a claim about this PR. Offline: needs no pull request, reads nothing and writes nothing. Never a verdict: nothing passes or fails, and nothing is written to a record. The same text as \`dunstan suggest\`.`;
43345
43477
  var DESCRIPTION = `Check a completion handback against the repository's own record, before you say done. Give the subject (repository owner/name and pull request number) and exactly one of: block, the dunstan-handback block as a JSON object; or report, the report text that carries exactly one \`\`\`dunstan-handback fence. Of the checks claims, checks.allSucceeded is true only if every counted check run concluded success, so a skipped or neutral run makes it false; checks.noneFailed is true if none concluded other than success, skipped or neutral and at least one succeeded. Returns the verdict (pass, fail or unverifiable), the claims table, and the full record as JSON. Same checker and same verdict as \`dunstan check\` on the same evidence; no model decides. Reads GitHub metadata with GITHUB_TOKEN and writes nothing to GitHub or the repository; the record is written to a file only when you pass outPath. If the verdict is not pass, report the rows as they are; change the work, not the block.`;
43478
+ var TEMPLATE_DESCRIPTION = `Generate your dunstan-handback block from the local git checkout of your pull request's branch, after your last push: headCommit is HEAD, filesChanged is HEAD's diff against its merge base with base. Give directory, the checkout's absolute path. To declare tests, give tests.from "ci" with command, workflow and job once CI has finished on your last push: the counts are read from the JUnit record CI published, as the gate reads it, with GITHUB_TOKEN. tests.from "local" declares a local JUnit file as a local run, which the gate reports unverifiable. Returns the first line (tests: declared, tests: not declared, or tests: none asked) and the notes, then the fence, byte for byte what \`dunstan template\` prints. When the first line is tests: not declared, the block has no tests: do not add counts yourself; do what the note says. Add your references and checks yourself. Reads git and, with tests.from "ci", GitHub metadata and the JUnit artifact for its counts; writes nothing.`;
43346
43479
  function reportOfBlock(block) {
43347
43480
  const text2 = canonicalize(block);
43348
43481
  return {
@@ -43421,8 +43554,47 @@ function handbackResult(record2, outPath, notes, context) {
43421
43554
  ]
43422
43555
  };
43423
43556
  }
43424
- function toolError(error62) {
43425
- const known = error62 instanceof InputError || error62 instanceof PullRequestUnreadable || error62 instanceof PullRequestMoved || error62 instanceof CanonicalizationError || typeof error62.code === "string";
43557
+ var TESTS_LINE = {
43558
+ declared: "tests: declared",
43559
+ not_declared: "tests: not declared",
43560
+ none: "tests: none asked"
43561
+ };
43562
+ async function templateHandback(input2, options) {
43563
+ if (!isAbsolute(input2.directory)) {
43564
+ throw new InputError(`directory ${input2.directory} is not an absolute path`);
43565
+ }
43566
+ const warnings = [];
43567
+ const run = await runTemplate(
43568
+ { cwd: input2.directory, base: input2.base, tests: input2.tests },
43569
+ {
43570
+ env: options.env,
43571
+ fetch: options.fetch,
43572
+ warn: (message) => {
43573
+ warnings.push(message);
43574
+ options.warn(`dunstan: ${message}
43575
+ `);
43576
+ }
43577
+ }
43578
+ );
43579
+ const head = [TESTS_LINE[run.tests]];
43580
+ if (run.tests === "not_declared") {
43581
+ head.push("The block below has no tests. Do not add counts yourself; do what the note says.");
43582
+ }
43583
+ head.push(...[...warnings, ...run.notes].map((n) => `note: ${n}`));
43584
+ return {
43585
+ content: [
43586
+ { type: "text", text: `${head.join("\n")}
43587
+ ` },
43588
+ { type: "text", text: run.fence }
43589
+ ]
43590
+ };
43591
+ }
43592
+ var ERROR_TAIL = {
43593
+ check: "This is an error, not a verdict: nothing was checked.",
43594
+ template: "This is an error: no block was produced."
43595
+ };
43596
+ function toolError(error62, tail = "check") {
43597
+ const known = error62 instanceof InputError || error62 instanceof PullRequestUnreadable || error62 instanceof PullRequestMoved || error62 instanceof CanonicalizationError || error62 instanceof TemplateError || typeof error62.code === "string";
43426
43598
  const message = error62.message ?? String(error62);
43427
43599
  return {
43428
43600
  isError: true,
@@ -43430,12 +43602,37 @@ function toolError(error62) {
43430
43602
  {
43431
43603
  type: "text",
43432
43604
  text: `dunstan: ${known ? message : `internal error: ${message}`}
43433
- This is an error, not a verdict: nothing was checked.
43605
+ ${ERROR_TAIL[tail]}
43434
43606
  `
43435
43607
  }
43436
43608
  ]
43437
43609
  };
43438
43610
  }
43611
+ var nonEmpty = external_exports.string().min(1);
43612
+ var TEMPLATE_TESTS = external_exports.discriminatedUnion("from", [
43613
+ external_exports.object({
43614
+ from: external_exports.literal("ci"),
43615
+ command: nonEmpty.describe("The command that ran the tests, as the block declares it."),
43616
+ workflow: nonEmpty.describe(
43617
+ "The workflow file that ran them, as a path: .github/workflows/ci.yml."
43618
+ ),
43619
+ job: nonEmpty.describe("The name of the job that uploaded the JUnit artifact."),
43620
+ artifact: nonEmpty.optional().describe("The artifact holding the JUnit file. Absent: dunstan-junit."),
43621
+ artifactPath: nonEmpty.optional().describe("The JUnit file in the artifact. Absent: junit.xml."),
43622
+ repository: external_exports.string().regex(REPOSITORY, "repository must be owner/name").optional().describe("The GitHub repository, owner/name. Absent: the one the origin remote names.")
43623
+ }).strict().describe(
43624
+ "Read CI's counts at HEAD from the JUnit record CI published, as the gate reads it (dunstan template --tests-ci)."
43625
+ ),
43626
+ external_exports.object({
43627
+ from: external_exports.literal("local"),
43628
+ junit: nonEmpty.describe(
43629
+ "The local JUnit XML file; a relative path resolves against directory."
43630
+ ),
43631
+ command: nonEmpty.describe("The command that ran the tests, as the block declares it.")
43632
+ }).strict().describe(
43633
+ "Declare a local JUnit file as a local run, which the gate reports unverifiable (dunstan template --tests-local)."
43634
+ )
43635
+ ]);
43439
43636
  function createDunstanMcpServer(options) {
43440
43637
  const server = new McpServer(
43441
43638
  { name: CHECKER_NAME, version: CHECKER_VERSION },
@@ -43489,6 +43686,37 @@ function createDunstanMcpServer(options) {
43489
43686
  },
43490
43687
  async (input2) => ({ content: [{ type: "text", text: suggestText(input2.report) }] })
43491
43688
  );
43689
+ server.registerTool(
43690
+ TEMPLATE_TOOL_NAME,
43691
+ {
43692
+ title: "Generate a handback block from git",
43693
+ description: TEMPLATE_DESCRIPTION,
43694
+ inputSchema: external_exports.object({
43695
+ directory: nonEmpty.describe(
43696
+ "The absolute path of the local git checkout of the pull request's branch. Git runs there."
43697
+ ),
43698
+ base: nonEmpty.optional().describe(
43699
+ "The ref the pull request merges into, as dunstan template --base. Absent: the remote's default branch."
43700
+ ),
43701
+ tests: TEMPLATE_TESTS.optional().describe(
43702
+ "Which tests to declare. Absent: none, and the note says how."
43703
+ )
43704
+ }).strict(),
43705
+ annotations: {
43706
+ readOnlyHint: true,
43707
+ destructiveHint: false,
43708
+ idempotentHint: true,
43709
+ openWorldHint: true
43710
+ }
43711
+ },
43712
+ async (input2) => {
43713
+ try {
43714
+ return await templateHandback(input2, options);
43715
+ } catch (error62) {
43716
+ return toolError(error62, "template");
43717
+ }
43718
+ }
43719
+ );
43492
43720
  return server;
43493
43721
  }
43494
43722
 
@@ -43779,6 +44007,7 @@ function aggregate(entries, listed2, advisory, reader) {
43779
44007
  // src/survey/record.ts
43780
44008
  var SURVEY_FORMAT = "dunstan-survey-0.1-draft";
43781
44009
  var LISTING_PER_PAGE = 100;
44010
+ var LISTING_READS = 3;
43782
44011
  var DEFAULT_LAST = 30;
43783
44012
  var MAX_LAST = 200;
43784
44013
  function appendListed(listed2, seen, items) {
@@ -43896,6 +44125,15 @@ function listedOf(item) {
43896
44125
  };
43897
44126
  }
43898
44127
  async function readListing(client, parameters, now, pageBound) {
44128
+ const abandoned = [];
44129
+ for (; ; ) {
44130
+ const last = abandoned.length === LISTING_READS - 1;
44131
+ const read = await readOnce(client, parameters, now, pageBound, !last);
44132
+ if ("end" in read) return { ...read, abandoned };
44133
+ abandoned.push(read);
44134
+ }
44135
+ }
44136
+ async function readOnce(client, parameters, now, pageBound, abandonOnRepeat) {
43899
44137
  const pages = [];
43900
44138
  const pullRequests = [];
43901
44139
  const seen = /* @__PURE__ */ new Set();
@@ -43928,6 +44166,7 @@ async function readListing(client, parameters, now, pageBound) {
43928
44166
  }
43929
44167
  }
43930
44168
  pages.push(entry);
44169
+ if (abandonOnRepeat && entry.duplicates > 0) return { pages };
43931
44170
  const end = listingStop(parameters, pages, pullRequests);
43932
44171
  if (end !== null) return done(end);
43933
44172
  }
@@ -44022,7 +44261,7 @@ function share(r) {
44022
44261
  function interval(r) {
44023
44262
  return r.wilson95 === null ? "n/a" : `${r.wilson95.low}\u2013${r.wilson95.high}`;
44024
44263
  }
44025
- function plural2(n, one, many) {
44264
+ function plural3(n, one, many) {
44026
44265
  return `${n} ${n === 1 ? one : many}`;
44027
44266
  }
44028
44267
  function renderSurveySummary(record2) {
@@ -44046,7 +44285,7 @@ function renderSurveySummary(record2) {
44046
44285
  "",
44047
44286
  "## Claims the record could not settle",
44048
44287
  "",
44049
- `The record could not settle ${a.cannotSettle.numerator} of ${plural2(a.cannotSettle.denominator, "claim", "claims")}.`,
44288
+ `The record could not settle ${a.cannotSettle.numerator} of ${plural3(a.cannotSettle.denominator, "claim", "claims")}.`,
44050
44289
  ""
44051
44290
  );
44052
44291
  if (a.unsettled.length === 0) {
@@ -44118,7 +44357,7 @@ function renderSurveySummary(record2) {
44118
44357
  "",
44119
44358
  "## Advisory notes",
44120
44359
  "",
44121
- `The advisory reader read the prose of ${plural2(a.advisory.pullRequests, "pull request", "pull requests")}. Its notes never enter either rate, and a possible disagreement is unverified.`,
44360
+ `The advisory reader read the prose of ${plural3(a.advisory.pullRequests, "pull request", "pull requests")}. Its notes never enter either rate, and a possible disagreement is unverified.`,
44122
44361
  ""
44123
44362
  );
44124
44363
  if (a.advisory.notes.length === 0) lines.push("No notes.");
@@ -44151,19 +44390,26 @@ function renderSurveySummary(record2) {
44151
44390
  ]
44152
44391
  ),
44153
44392
  "",
44154
- `The listing was read for ${plural2(listing.pages.length, "page", "pages")} and no further because ${listing.end === "page_bound" ? `it reached the bound of ${listing.pageBound} pages` : END_TEXT[listing.end]}.`
44393
+ `The listing was read for ${plural3(listing.pages.length, "page", "pages")} and no further because ${listing.end === "page_bound" ? `it reached the bound of ${listing.pageBound} pages` : END_TEXT[listing.end]}.`
44155
44394
  );
44395
+ const abandoned = listing.abandoned.length;
44156
44396
  if (listing.duplicates > 0) {
44157
44397
  const one = listing.duplicates === 1;
44398
+ const reread = abandoned === 0 ? "" : ` It was read ${abandoned + 1} times, from the first page each time, and moved during every read.`;
44158
44399
  lines.push(
44159
44400
  "",
44160
- `The listing named ${plural2(listing.duplicates, "pull request", "pull requests")} again on a later page, so it changed while it was read: most likely ${plural2(listing.duplicates, "pull request", "pull requests")} closed meanwhile and ${one ? "was" : "were"} not listed, and the selection may be missing ${one ? "it" : "them"}. Each repeated pull request is listed, selected and counted once: the pages held ${listing.pullRequests.length + listing.duplicates} items for ${listing.pullRequests.length} pull requests.`
44401
+ `The listing named ${plural3(listing.duplicates, "pull request", "pull requests")} again on a later page, so it changed while it was read: most likely ${plural3(listing.duplicates, "pull request", "pull requests")} closed meanwhile and ${one ? "was" : "were"} not listed, and the selection may be missing ${one ? "it" : "them"}. Each repeated pull request is listed, selected and counted once: the pages held ${listing.pullRequests.length + listing.duplicates} items for ${listing.pullRequests.length} pull requests.${reread}`
44402
+ );
44403
+ } else if (abandoned > 0) {
44404
+ lines.push(
44405
+ "",
44406
+ `The listing moved while it was read: ${plural3(abandoned, "read was", "reads were")} abandoned at a repeated pull request and the listing was read again from the first page. The read used repeated no pull request.`
44161
44407
  );
44162
44408
  }
44163
44409
  if (selected.length < parameters.last) {
44164
44410
  lines.push(
44165
44411
  "",
44166
- `${plural2(selected.length, "pull request was", "pull requests were")} selected, fewer than the ${parameters.last} asked for.`
44412
+ `${plural3(selected.length, "pull request was", "pull requests were")} selected, fewer than the ${parameters.last} asked for.`
44167
44413
  );
44168
44414
  }
44169
44415
  lines.push(
@@ -44250,95 +44496,12 @@ summary: ${values.summary}
44250
44496
 
44251
44497
  // src/cli/template.ts
44252
44498
  import { parseArgs as parseArgs6 } from "node:util";
44253
-
44254
- // src/template/tests-ci.ts
44255
- var plural3 = (n, one, many) => `${n} ${n === 1 ? one : many}`;
44256
- function unsettled(reason, why, remedy) {
44257
- const notes = [`tests are not declared: ${why} (${reason}).`];
44258
- if (remedy !== void 0) notes.push(`remedy: ${remedy}`);
44259
- return { status: "unsettled", reason, notes };
44260
- }
44261
- async function testsFromCi(client, input2) {
44262
- const { repository, headCommit: head, record: record2 } = input2;
44263
- if (!client.hasToken) {
44264
- return unsettled(
44265
- "no_token",
44266
- "GITHUB_TOKEN is not set, and GitHub answers an artifact download only with a token, even on a public repository",
44267
- "set GITHUB_TOKEN to a token that can read the repository, with actions: read, and run dunstan template --tests-ci again."
44268
- );
44269
- }
44270
- if (!input2.pushed) {
44271
- return unsettled(
44272
- "not_pushed",
44273
- `HEAD ${head} is not on any remote-tracking branch, so no CI run can have tested it`,
44274
- "push it, wait for CI to finish, and run dunstan template --tests-ci again (run git fetch first if it was pushed from elsewhere)."
44275
- );
44276
- }
44277
- const observed = { runsNotCompleted: 0 };
44278
- const evidence = await readTestRecord(client, repository, record2, head, observed);
44279
- const named = `the artifact ${record2.artifact} holding ${record2.path} from job ${record2.job}`;
44280
- const pending = observed.runsNotCompleted > 0 ? `CI has not finished: ${plural3(observed.runsNotCompleted, "run", "runs")} of ${record2.workflow} at ${head} ${observed.runsNotCompleted === 1 ? "is" : "are"} not completed` : void 0;
44281
- const again = "run dunstan template --tests-ci again once CI has finished on this commit.";
44282
- if (evidence.status !== "ok") {
44283
- const reason = evidence.status === "unreadable" ? `source_unreadable:${evidence.source}` : `evidence_field_unpopulated:${evidence.field}`;
44284
- if (pending !== void 0) return unsettled(reason, pending, again);
44285
- const why = reason === "source_unreadable:artifact" ? `a run of ${record2.workflow} at ${head} uploaded ${record2.artifact}, and it could not be read` : `the workflow runs of ${record2.workflow} at ${head} could not be read`;
44286
- return unsettled(
44287
- reason,
44288
- why,
44289
- remedyForReason(reason, { hasToken: true }) ?? "run dunstan template --tests-ci again."
44290
- );
44291
- }
44292
- if (evidence.runs.length === 0) {
44293
- if (pending !== void 0) {
44294
- return unsettled(
44295
- "record_not_found",
44296
- `${pending}, and no completed run uploaded ${named}`,
44297
- again
44298
- );
44299
- }
44300
- return unsettled(
44301
- "record_not_found",
44302
- `no run of ${record2.workflow} at ${head} uploaded ${named}`,
44303
- remedyForReason("record_not_found", { hasToken: true })
44304
- );
44305
- }
44306
- const executed = new Set(evidence.runs.map((r) => r.executed));
44307
- const failed = new Set(evidence.runs.map((r) => r.failed));
44308
- if (executed.size > 1 || failed.size > 1) {
44309
- const counts = evidence.runs.map((r) => `run ${r.runId}: ${r.executed} executed, ${r.failed} failed`).join("; ");
44310
- return unsettled(
44311
- "record_ambiguous",
44312
- `the runs of ${record2.workflow} at ${head} that uploaded ${record2.artifact} disagree (${counts}), and the gate never picks one`,
44313
- "make the runs agree (re-run the one that differs), or name a job only one of them runs."
44314
- );
44315
- }
44316
- const run = evidence.runs[0];
44317
- const runs = evidence.runs.map((r) => r.runId).join(", ");
44318
- const notes = [
44319
- `tests: ${run.executed} executed and ${run.failed} failed in ${record2.path} in the artifact ${record2.artifact} of job ${record2.job} in ${record2.workflow} at ${head}, read from CI as the gate reads it (${evidence.runs.length === 1 ? "run" : "runs"} ${runs}).`
44320
- ];
44321
- if (pending !== void 0) {
44322
- notes.push(
44323
- `${pending}; a run that finishes later with other counts makes the gate's read ambiguous. Run dunstan template --tests-ci again once CI has finished.`
44324
- );
44325
- }
44326
- return {
44327
- status: "settled",
44328
- run: { command: input2.command, count: run.executed, failures: run.failed, record: record2 },
44329
- notes
44330
- };
44331
- }
44332
-
44333
- // src/cli/template.ts
44334
44499
  var RECORD_FLAGS = [
44335
44500
  "tests-workflow",
44336
44501
  "tests-job",
44337
44502
  "tests-artifact",
44338
44503
  "tests-artifact-path"
44339
44504
  ];
44340
- var DEFAULT_ARTIFACT = "dunstan-junit";
44341
- var DEFAULT_ARTIFACT_PATH = "junit.xml";
44342
44505
  async function template(args, io) {
44343
44506
  const { values } = parseArgs6({
44344
44507
  args,
@@ -44363,7 +44526,6 @@ async function template(args, io) {
44363
44526
  }
44364
44527
  if (flag("repo") !== void 0 && !ci) throw new UsageError("--repo goes with --tests-ci");
44365
44528
  const given = ["tests-command", ...RECORD_FLAGS].filter((f) => flag(f) !== void 0);
44366
- const cwd = io.cwd ?? process.cwd();
44367
44529
  let tests;
44368
44530
  if (ci) {
44369
44531
  const missing = ["tests-command", "tests-workflow", "tests-job"].filter(
@@ -44376,28 +44538,15 @@ async function template(args, io) {
44376
44538
  if (repo !== void 0 && !REPOSITORY.test(repo)) {
44377
44539
  throw new UsageError("--repo must be <owner/name>");
44378
44540
  }
44379
- const head = headCommit(cwd);
44380
- const repository = repo ?? originRepository(cwd);
44381
- const client = new GitHubClient({
44382
- token: io.env.GITHUB_TOKEN,
44383
- ...io.fetch === void 0 ? {} : { fetch: io.fetch },
44384
- warn: (message) => io.err(`dunstan: ${message}
44385
- `)
44386
- });
44387
- const read = await testsFromCi(client, {
44388
- repository,
44389
- headCommit: head,
44541
+ tests = {
44542
+ from: "ci",
44390
44543
  command: flag("tests-command"),
44391
- pushed: isPushed(cwd, head),
44392
- record: {
44393
- kind: "junit",
44394
- workflow: flag("tests-workflow"),
44395
- job: flag("tests-job"),
44396
- artifact: flag("tests-artifact") ?? DEFAULT_ARTIFACT,
44397
- path: flag("tests-artifact-path") ?? DEFAULT_ARTIFACT_PATH
44398
- }
44399
- });
44400
- tests = { kind: "ci", read };
44544
+ workflow: flag("tests-workflow"),
44545
+ job: flag("tests-job"),
44546
+ artifact: flag("tests-artifact"),
44547
+ artifactPath: flag("tests-artifact-path"),
44548
+ repository: repo
44549
+ };
44401
44550
  } else if (flag("tests-local") !== void 0) {
44402
44551
  if (flag("tests-command") === void 0) {
44403
44552
  throw new UsageError("--tests-local needs --tests-command <cmd>");
@@ -44405,7 +44554,7 @@ async function template(args, io) {
44405
44554
  const extra = RECORD_FLAGS.find((f) => flag(f) !== void 0);
44406
44555
  if (extra !== void 0) throw new UsageError(`--${extra} does not go with --tests-local`);
44407
44556
  tests = {
44408
- kind: "local",
44557
+ from: "local",
44409
44558
  junit: flag("tests-local"),
44410
44559
  command: flag("tests-command")
44411
44560
  };
@@ -44419,24 +44568,26 @@ async function template(args, io) {
44419
44568
  );
44420
44569
  }
44421
44570
  tests = {
44422
- kind: "junit",
44571
+ from: "junit",
44423
44572
  junit: flag("tests-junit"),
44424
44573
  command: flag("tests-command"),
44425
- record: {
44426
- workflow: flag("tests-workflow"),
44427
- job: flag("tests-job"),
44428
- artifact: flag("tests-artifact"),
44429
- path: flag("tests-artifact-path")
44430
- }
44574
+ workflow: flag("tests-workflow"),
44575
+ job: flag("tests-job"),
44576
+ artifact: flag("tests-artifact"),
44577
+ artifactPath: flag("tests-artifact-path")
44431
44578
  };
44432
44579
  } else if (given.length > 0) {
44433
44580
  throw new UsageError(`--${given[0]} goes with --tests-ci, --tests-local or --tests-junit`);
44434
44581
  }
44435
- const t = templateBlock({ cwd, base: flag("base"), tests });
44436
- io.out(renderBlock(t.block));
44437
- for (const note of t.notes) io.err(`dunstan: ${note}
44582
+ const run = await runTemplate(
44583
+ { cwd: io.cwd ?? process.cwd(), base: flag("base"), tests },
44584
+ { env: io.env, fetch: io.fetch, warn: (message) => io.err(`dunstan: ${message}
44585
+ `) }
44586
+ );
44587
+ io.out(run.fence);
44588
+ for (const note of run.notes) io.err(`dunstan: ${note}
44438
44589
  `);
44439
- return tests?.kind === "ci" && tests.read.status === "unsettled" ? EXIT.unverifiable : 0;
44590
+ return run.tests === "not_declared" ? EXIT.unverifiable : 0;
44440
44591
  }
44441
44592
 
44442
44593
  // src/cli/verify.ts
@@ -44583,6 +44734,68 @@ function at(value, pointer, base) {
44583
44734
  }
44584
44735
  return v;
44585
44736
  }
44737
+ function abandonedProblems(listing, repeated) {
44738
+ const problems = [];
44739
+ const base = "/selection/listing/abandoned";
44740
+ const { abandoned } = listing;
44741
+ abandoned.forEach((read, r) => {
44742
+ if (read.pages.length > listing.pageBound) {
44743
+ problems.push({
44744
+ member: `${base}/${r}/pages`,
44745
+ message: `${read.pages.length} pages read, past the bound of ${listing.pageBound}`
44746
+ });
44747
+ }
44748
+ read.pages.forEach((page, i) => {
44749
+ const at2 = `${base}/${r}/pages/${i}`;
44750
+ if (page.page !== i + 1) {
44751
+ problems.push({
44752
+ member: `${at2}/page`,
44753
+ message: `recorded ${page.page}; a read runs from page 1 without a gap, so this is page ${i + 1}`
44754
+ });
44755
+ }
44756
+ if (i < read.pages.length - 1) {
44757
+ if (page.duplicates !== 0) {
44758
+ problems.push({
44759
+ member: `${at2}/duplicates`,
44760
+ message: `recorded ${page.duplicates}; a read is abandoned at its first page that repeats an earlier item, so no page before its last repeats one`
44761
+ });
44762
+ }
44763
+ if (page.error !== void 0) {
44764
+ problems.push({
44765
+ member: `${at2}/error`,
44766
+ message: `recorded ${text(page.error)}; a page that could not be read ends its read, so only a last page has an error`
44767
+ });
44768
+ }
44769
+ } else if (page.duplicates === 0 || page.duplicates > page.items) {
44770
+ problems.push({
44771
+ member: `${at2}/duplicates`,
44772
+ message: page.duplicates === 0 ? "recorded 0; an abandoned read ends at a page that repeats an earlier item" : `recorded ${page.duplicates}, more than the page's ${page.items} items`
44773
+ });
44774
+ }
44775
+ });
44776
+ });
44777
+ if (repeated > 0 && abandoned.length !== LISTING_READS - 1) {
44778
+ problems.push({
44779
+ member: base,
44780
+ message: `${abandoned.length} reads abandoned, but the read used repeated ${repeated} items: a read that repeats is abandoned unless it is the last of ${LISTING_READS} reads, so ${LISTING_READS - 1} reads were abandoned before it`
44781
+ });
44782
+ }
44783
+ const reads = [...abandoned.map((read) => read.pages), listing.pages];
44784
+ reads.slice(1).forEach((later2, r) => {
44785
+ const first = later2[0];
44786
+ if (first === void 0) return;
44787
+ const next = Date.parse(first.readAt);
44788
+ (reads[r] ?? []).forEach((page, i) => {
44789
+ if (Date.parse(page.readAt) > next) {
44790
+ problems.push({
44791
+ member: `${base}/${r}/pages/${i}/readAt`,
44792
+ message: `recorded ${text(page.readAt)}, after the next read's first page (${text(first.readAt)})`
44793
+ });
44794
+ }
44795
+ });
44796
+ });
44797
+ return problems;
44798
+ }
44586
44799
  function verifySurveyRecord(value) {
44587
44800
  const schemaErrors = validateSurveyRecord(value);
44588
44801
  if (schemaErrors.length > 0) {
@@ -44672,6 +44885,7 @@ function verifySurveyRecord(value) {
44672
44885
  message: `${listing.pages.length} pages read, past the bound of ${listing.pageBound}`
44673
44886
  });
44674
44887
  }
44888
+ problems.push(...abandonedProblems(listing, repeated));
44675
44889
  const selected = selectPullRequests(parameters, listing.pullRequests);
44676
44890
  differ("/selection/selected", selection.selected, selected);
44677
44891
  differ(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dunstan",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "mcpName": "io.github.BargLabs/dunstan",
5
5
  "description": "Holds a coding agent's completion report to the record: deterministic checks of a declared handback block against the repository's own metadata.",
6
6
  "license": "AGPL-3.0-only",