dunstan 0.1.10 → 0.1.11

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 +11 -2
  2. package/dist/dunstan.mjs +143 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,7 +29,7 @@ report a pull request's handback before it says done ([`plugins/dunstan/`](plugi
29
29
  /plugin install dunstan@barglabs
30
30
  ```
31
31
 
32
- The plugin runs the MCP server with `npx -y dunstan@0.1.10 mcp`, the `dunstan` npm package at that
32
+ The plugin runs the MCP server with `npx -y dunstan@0.1.11 mcp`, the `dunstan` npm package at that
33
33
  exact version: one self-contained file, no dependencies.
34
34
 
35
35
  **The token.** When you enable the plugin, Claude Code asks for an optional GitHub token and keeps it
@@ -76,7 +76,9 @@ dist/dunstan.mjs verify record.json # offline: recompute claims, verdict and d
76
76
  dist/dunstan.mjs rerun record.json # online: re-read the sources, report evidence_changed
77
77
  ```
78
78
 
79
- Exit codes: 0 pass, 1 fail, 2 unverifiable, 3 usage or internal error (`verify`: 0 verified, 1 not).
79
+ Exit codes: 0 pass, 1 fail, 2 unverifiable, 3 no record: a usage error, a pull request that cannot
80
+ be read or that moved while it was checked and again when checked once more (`pull_request_moved`),
81
+ or an internal error (`verify`: 0 verified, 1 not).
80
82
  `--sign-key <path> --signer <principal>` writes the record as its JCS bytes with a detached SSH
81
83
  signature; `verify --sig <file> --allowed-signers <file>` checks it.
82
84
 
@@ -87,6 +89,13 @@ agent calls with its block before it says done, beside `dunstan_suggest_declarat
87
89
  `checker.digest` in a record is the SHA-256 of `dist/dunstan.mjs`, the single-file bundle that ran:
88
90
  the checker code, its dependencies and both schemas from `spec/schema/`, inlined at build time.
89
91
 
92
+ ## What Dunstan does not check
93
+
94
+ Dunstan checks the claims a handback declares against the record. It does not check that the
95
+ handback covers what the issue asked for: an accurate report of the wrong work passes.
96
+
97
+ What to declare: [`docs/agents.md`](docs/agents.md#the-instruction-block).
98
+
90
99
  ## Declare before done
91
100
 
92
101
  Checking a declared claim is exact. Finding the claims in prose is where readers fail: a hosted
package/dist/dunstan.mjs CHANGED
@@ -8030,7 +8030,7 @@ import { readFileSync as readFileSync7 } from "node:fs";
8030
8030
  import { createHash } from "node:crypto";
8031
8031
 
8032
8032
  // src/spec/constants.ts
8033
- var SPEC_VERSION = "0.1.6";
8033
+ var SPEC_VERSION = "0.1.7";
8034
8034
  var BLOCK_VERSION = "0.1";
8035
8035
  var SUPPORTED_BLOCK_VERSIONS = [BLOCK_VERSION];
8036
8036
  var BLOCK_INFO_STRING = "dunstan-handback";
@@ -8589,17 +8589,32 @@ function later(a, b) {
8589
8589
  if ((a.runAttempt ?? 0) !== (b.runAttempt ?? 0)) return (a.runAttempt ?? 0) > (b.runAttempt ?? 0);
8590
8590
  return a.id > b.id;
8591
8591
  }
8592
+ function identity(run) {
8593
+ if (run.workflowPath !== void 0)
8594
+ return JSON.stringify(["workflow", run.workflowPath, run.name]);
8595
+ if (run.appSlug === "github-actions") return void 0;
8596
+ if (run.appSlug !== void 0) return JSON.stringify(["app", run.appSlug, run.name]);
8597
+ if (run.checkSuiteId === void 0) return JSON.stringify(["name", run.name]);
8598
+ return void 0;
8599
+ }
8592
8600
  function countedCheckRuns(runs, excludedIds) {
8593
8601
  const excluded = new Set(excludedIds);
8594
8602
  const checker = /* @__PURE__ */ new Map();
8595
8603
  const latest = /* @__PURE__ */ new Map();
8596
8604
  for (const run of runs) {
8597
- if (excluded.has(run.id)) checker.set(run.name, Math.max(checker.get(run.name) ?? 0, run.id));
8605
+ const id = identity(run);
8606
+ if (excluded.has(run.id) && id !== void 0) {
8607
+ checker.set(id, Math.max(checker.get(id) ?? 0, run.id));
8608
+ }
8598
8609
  const key = checkKey(run);
8599
8610
  const seen = latest.get(key);
8600
8611
  if (seen === void 0 || later(run, seen)) latest.set(key, run);
8601
8612
  }
8602
- return [...latest.values()].filter((run) => !excluded.has(run.id) && run.id > (checker.get(run.name) ?? 0)).sort((a, b) => a.id - b.id);
8613
+ const superseded = (run) => {
8614
+ const id = identity(run);
8615
+ return id !== void 0 && run.id < (checker.get(id) ?? 0);
8616
+ };
8617
+ return [...latest.values()].filter((run) => !excluded.has(run.id) && !superseded(run)).sort((a, b) => a.id - b.id);
8603
8618
  }
8604
8619
  var NOT_FAILED_CONCLUSIONS = ["success", "skipped", "neutral"];
8605
8620
  function booleanRow(field, declared, reason, runs, failed, mismatch, unsettled) {
@@ -8921,6 +8936,14 @@ var PullRequestUnreadable = class extends Error {
8921
8936
  this.name = "PullRequestUnreadable";
8922
8937
  }
8923
8938
  };
8939
+ var PullRequestMoved = class extends Error {
8940
+ constructor(repository, number4, moves) {
8941
+ super(
8942
+ `pull request ${repository}#${number4} moved while it was checked, and again when it was checked once more (${moves.join(", ")}); no record is written (pull_request_moved)`
8943
+ );
8944
+ this.name = "PullRequestMoved";
8945
+ }
8946
+ };
8924
8947
  var ShapeError = class extends Error {
8925
8948
  };
8926
8949
  function obj(value) {
@@ -8959,8 +8982,12 @@ function timestamp(value) {
8959
8982
  function second(t) {
8960
8983
  return t.slice(0, 19);
8961
8984
  }
8962
- async function readPullRequest(client, repository, number4) {
8963
- const read = await client.read("pull_request", `/repos/${repository}/pulls/${number4}`);
8985
+ async function readPullRequest(client, repository, number4, options = {}) {
8986
+ const read = await client.read(
8987
+ "pull_request",
8988
+ `/repos/${repository}/pulls/${number4}`,
8989
+ options.record === false ? { record: false } : {}
8990
+ );
8964
8991
  if (!read.ok) {
8965
8992
  throw new PullRequestUnreadable(
8966
8993
  `cannot read pull request ${repository}#${number4} (${read.error}); no record is written`
@@ -8972,6 +8999,7 @@ async function readPullRequest(client, repository, number4) {
8972
8999
  const state = str(pr.state);
8973
9000
  if (state !== "open" && state !== "closed") throw new ShapeError();
8974
9001
  const base = obj(obj(pr.base).repo);
9002
+ const baseRef = str(obj(pr.base).ref);
8975
9003
  return {
8976
9004
  evidence: {
8977
9005
  status: "ok",
@@ -8985,6 +9013,7 @@ async function readPullRequest(client, repository, number4) {
8985
9013
  changedFiles: int(pr.changed_files)
8986
9014
  },
8987
9015
  repository: str(base.full_name),
9016
+ baseRef,
8988
9017
  body: strOrNull(pr.body)
8989
9018
  };
8990
9019
  } catch (e) {
@@ -8996,6 +9025,20 @@ async function readPullRequest(client, repository, number4) {
8996
9025
  throw e;
8997
9026
  }
8998
9027
  }
9028
+ function pullRequestMoves(first, again) {
9029
+ const state = (pr) => pr.evidence.merged ? "merged" : pr.evidence.state;
9030
+ const pairs = [
9031
+ ["head", first.evidence.headSha, again.evidence.headSha],
9032
+ ["base", first.baseRef, again.baseRef],
9033
+ ["state", state(first), state(again)],
9034
+ ["repository", first.repository, again.repository],
9035
+ ["mergedAt", first.evidence.mergedAt, again.evidence.mergedAt],
9036
+ ["mergeSha", first.evidence.mergeSha, again.evidence.mergeSha],
9037
+ ["changedFiles", first.evidence.changedFiles, again.evidence.changedFiles],
9038
+ ["number", first.evidence.number, again.evidence.number]
9039
+ ];
9040
+ return pairs.filter(([, a, b]) => a !== b).map(([name, a, b]) => `${name} ${a} -> ${b}`);
9041
+ }
8999
9042
  async function readAllPages(client, kind, pathFor, itemsOf, map2, options = {}) {
9000
9043
  const out = [];
9001
9044
  for (let page = 1; page <= MAX_PAGES; page++) {
@@ -9775,7 +9818,7 @@ async function reportFromComment(client, repository, number4, login) {
9775
9818
  // src/record/checker.ts
9776
9819
  import { readFileSync } from "node:fs";
9777
9820
  var CHECKER_NAME = "dunstan";
9778
- var CHECKER_VERSION = "0.1.10";
9821
+ var CHECKER_VERSION = "0.1.11";
9779
9822
  function checkerIdentity(artifact) {
9780
9823
  return {
9781
9824
  name: CHECKER_NAME,
@@ -10748,6 +10791,8 @@ import { parseArgs } from "node:util";
10748
10791
 
10749
10792
  // src/evidence/http.ts
10750
10793
  var DEFAULT_API = "https://api.github.com";
10794
+ var MAX_ARCHIVE_BYTES = MAX_ENTRY_BYTES + 8 * 1024 * 1024;
10795
+ var MAX_BODY_BYTES = 48 * 1024 * 1024;
10751
10796
  var EMPTY = new Uint8Array();
10752
10797
  var GitHubClient = class {
10753
10798
  sources = /* @__PURE__ */ new Map();
@@ -10784,10 +10829,19 @@ var GitHubClient = class {
10784
10829
  async read(kind, path, options = {}) {
10785
10830
  const method = options.method ?? "GET";
10786
10831
  const locator = options.locator ?? `${method} ${path}${options.locatorSuffix ?? ""}`;
10787
- const result = await this.#fetchWithRetry(method, path, options.requestBody, locator);
10832
+ const budget = kind === "artifact" ? MAX_ARCHIVE_BYTES : MAX_BODY_BYTES;
10833
+ const result = await this.#fetchWithRetry(method, path, options.requestBody, locator, budget);
10788
10834
  let read;
10789
10835
  if (result.kind === "network") {
10790
10836
  read = { ok: false, status: null, error: "network", body: EMPTY };
10837
+ } else if (result.body === null) {
10838
+ const failed = result.status < 200 || result.status > 299;
10839
+ read = {
10840
+ ok: false,
10841
+ status: result.status,
10842
+ error: failed ? `http_${result.status}` : "parse",
10843
+ body: EMPTY
10844
+ };
10791
10845
  } else if (result.status < 200 || result.status > 299) {
10792
10846
  read = {
10793
10847
  ok: false,
@@ -10824,7 +10878,7 @@ var GitHubClient = class {
10824
10878
  const source = this.sources.get(locator);
10825
10879
  if (source !== void 0) source.error = "parse";
10826
10880
  }
10827
- async #fetchWithRetry(method, path, body, locator) {
10881
+ async #fetchWithRetry(method, path, body, locator, budget) {
10828
10882
  const headers = {
10829
10883
  accept: "application/vnd.github+json",
10830
10884
  "x-github-api-version": "2022-11-28",
@@ -10849,10 +10903,10 @@ var GitHubClient = class {
10849
10903
  this.#warn(`${locator}: network error after ${attempt} attempts (${e.message})`);
10850
10904
  return { kind: "network" };
10851
10905
  }
10852
- const bytes = new Uint8Array(await response.arrayBuffer());
10853
10906
  const status = response.status;
10854
10907
  const wait = retryWaitMs(response, status, this.#now(), attempt);
10855
10908
  if (wait !== void 0 && attempt < this.#maxAttempts && wait <= this.#maxWaitMs) {
10909
+ await cancel(response.body);
10856
10910
  await this.#sleep(wait);
10857
10911
  continue;
10858
10912
  }
@@ -10860,10 +10914,48 @@ var GitHubClient = class {
10860
10914
  const why = wait > this.#maxWaitMs ? `rate limited for ${Math.ceil(wait / 1e3)}s` : `${attempt} attempts`;
10861
10915
  this.#warn(`${locator}: giving up with HTTP ${status} (${why}); recorded as unreadable`);
10862
10916
  }
10917
+ const bytes = await readWithin(response, budget);
10918
+ if (bytes === null) {
10919
+ this.#warn(
10920
+ `${locator}: answer over ${budget} bytes, not read in full; recorded as unreadable`
10921
+ );
10922
+ }
10863
10923
  return { kind: "response", status, body: bytes, etag: response.headers.get("etag") };
10864
10924
  }
10865
10925
  }
10866
10926
  };
10927
+ async function cancel(body) {
10928
+ await body?.cancel().catch(() => {
10929
+ });
10930
+ }
10931
+ async function readWithin(response, budget) {
10932
+ const length = response.headers.get("content-length");
10933
+ if (length !== null && /^[0-9]+$/.test(length) && Number(length) > budget) {
10934
+ await cancel(response.body);
10935
+ return null;
10936
+ }
10937
+ if (response.body === null) return EMPTY;
10938
+ const reader = response.body.getReader();
10939
+ const chunks = [];
10940
+ let size = 0;
10941
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
10942
+ size += chunk.value.length;
10943
+ if (size > budget) {
10944
+ await reader.cancel().catch(() => {
10945
+ });
10946
+ return null;
10947
+ }
10948
+ chunks.push(chunk.value);
10949
+ }
10950
+ if (chunks.length === 1) return chunks[0];
10951
+ const bytes = new Uint8Array(size);
10952
+ let at = 0;
10953
+ for (const chunk of chunks) {
10954
+ bytes.set(chunk, at);
10955
+ at += chunk.length;
10956
+ }
10957
+ return bytes;
10958
+ }
10867
10959
  function backoffMs(attempt) {
10868
10960
  return Math.min(1e3 * 2 ** (attempt - 1), 8e3);
10869
10961
  }
@@ -13094,13 +13186,34 @@ function serializeRecord(record2) {
13094
13186
  // src/pipeline/check-pull-request.ts
13095
13187
  function githubReaders(client) {
13096
13188
  return {
13097
- pullRequest: (repository, number4) => readPullRequest(client, repository, number4),
13189
+ pullRequest: (repository, number4, options) => readPullRequest(client, repository, number4, options),
13098
13190
  evidence: (input2) => readEvidence(client, input2),
13099
- pathObjects: (input2) => readPathObjects(client, input2)
13191
+ pathObjects: (input2) => readPathObjects(client, input2),
13192
+ restart: () => client.sources.clear()
13100
13193
  };
13101
13194
  }
13195
+ async function atOnePullRequest(readers, repository, number4, first, collect) {
13196
+ let pr = first ?? await readers.pullRequest(repository, number4);
13197
+ for (let run = 1; ; run++) {
13198
+ const collected = await collect(pr);
13199
+ const again = await readers.pullRequest(repository, number4, { record: false });
13200
+ const moves = pullRequestMoves(pr, again);
13201
+ if (moves.length === 0) return collected;
13202
+ if (run === 2) throw new PullRequestMoved(repository, number4, moves);
13203
+ readers.restart?.();
13204
+ pr = await readers.pullRequest(repository, number4);
13205
+ }
13206
+ }
13102
13207
  async function checkPullRequest(input2) {
13103
- const pr = input2.pullRequest ?? await input2.readers.pullRequest(input2.repository, input2.number);
13208
+ return atOnePullRequest(
13209
+ input2.readers,
13210
+ input2.repository,
13211
+ input2.number,
13212
+ input2.pullRequest,
13213
+ (pr) => checkAt(input2, pr)
13214
+ );
13215
+ }
13216
+ async function checkAt(input2, pr) {
13104
13217
  const repository = pr.repository;
13105
13218
  const ownRuns = await input2.afterPullRequest?.(pr) ?? [];
13106
13219
  const report = await input2.report(pr);
@@ -40734,7 +40847,7 @@ var Protocol = class {
40734
40847
  }
40735
40848
  };
40736
40849
  }
40737
- const cancel = (reason) => {
40850
+ const cancel2 = (reason) => {
40738
40851
  this._responseHandlers.delete(messageId);
40739
40852
  this._progressHandlers.delete(messageId);
40740
40853
  this._cleanupTimeout(messageId);
@@ -40768,10 +40881,10 @@ var Protocol = class {
40768
40881
  }
40769
40882
  });
40770
40883
  options?.signal?.addEventListener("abort", () => {
40771
- cancel(options?.signal?.reason);
40884
+ cancel2(options?.signal?.reason);
40772
40885
  });
40773
40886
  const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
40774
- const timeoutHandler = () => cancel(McpError.fromError(ErrorCode.RequestTimeout, "Request timed out", { timeout }));
40887
+ const timeoutHandler = () => cancel2(McpError.fromError(ErrorCode.RequestTimeout, "Request timed out", { timeout }));
40775
40888
  this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false);
40776
40889
  const relatedTaskId = relatedTask?.taskId;
40777
40890
  if (relatedTaskId) {
@@ -42966,7 +43079,7 @@ function handbackResult(record2, outPath, notes) {
42966
43079
  };
42967
43080
  }
42968
43081
  function toolError(error62) {
42969
- const known = error62 instanceof InputError || error62 instanceof PullRequestUnreadable || error62 instanceof CanonicalizationError || typeof error62.code === "string";
43082
+ const known = error62 instanceof InputError || error62 instanceof PullRequestUnreadable || error62 instanceof PullRequestMoved || error62 instanceof CanonicalizationError || typeof error62.code === "string";
42970
43083
  const message = error62.message ?? String(error62);
42971
43084
  return {
42972
43085
  isError: true,
@@ -43105,14 +43218,20 @@ async function rerun(args, io) {
43105
43218
  warn: (message) => io.err(`dunstan: ${message}
43106
43219
  `)
43107
43220
  });
43108
- const pr = await readPullRequest(client, old.subject.repository, old.subject.pullRequest);
43109
43221
  const oldExcluded = old.evidence.checkRuns?.status === "ok" ? old.evidence.checkRuns.excludedIds : [];
43110
- const evidence = await readEvidence(client, {
43111
- repository: old.subject.repository,
43112
- pullRequest: pr,
43113
- block: old.block.status === "found" ? old.block.value : null,
43114
- excludedCheckRunIds: [...oldExcluded, ...parseCheckRunIds(values["exclude-check-run"])]
43115
- });
43222
+ const excluded = [...oldExcluded, ...parseCheckRunIds(values["exclude-check-run"])];
43223
+ const evidence = await atOnePullRequest(
43224
+ githubReaders(client),
43225
+ old.subject.repository,
43226
+ old.subject.pullRequest,
43227
+ void 0,
43228
+ (pr) => readEvidence(client, {
43229
+ repository: old.subject.repository,
43230
+ pullRequest: pr,
43231
+ block: old.block.status === "found" ? old.block.value : null,
43232
+ excludedCheckRunIds: excluded
43233
+ })
43234
+ );
43116
43235
  const record2 = buildRecord({
43117
43236
  checker: checkerIdentity(io.artifact),
43118
43237
  report: old.report,
@@ -43473,7 +43592,7 @@ async function main(argv, io) {
43473
43592
  io.err(`dunstan: ${e.message}
43474
43593
 
43475
43594
  ${USAGE}`);
43476
- } else if (e instanceof PullRequestUnreadable || e instanceof ReportUnavailable || e instanceof TemplateError) {
43595
+ } else if (e instanceof PullRequestUnreadable || e instanceof PullRequestMoved || e instanceof ReportUnavailable || e instanceof TemplateError) {
43477
43596
  io.err(`dunstan: ${e.message}
43478
43597
  `);
43479
43598
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dunstan",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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",