glasspane-mcp 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ export declare const MCP_PROTOCOL_VERSION: "2025-06-18";
26
26
  export declare const SUPPORTED_PROTOCOL_VERSIONS: readonly string[];
27
27
  export declare const SERVER_INFO: {
28
28
  readonly name: "glasspane-mcp";
29
- readonly version: "1.14.0";
29
+ readonly version: "1.15.0";
30
30
  };
31
31
  export declare const PARSE_ERROR = -32700;
32
32
  export declare const INVALID_REQUEST = -32600;
@@ -63,6 +63,13 @@ export interface McpServerDeps {
63
63
  * request it refers to stayed in flight and kept acting on the user's screen.
64
64
  */
65
65
  report?: (note: string) => void;
66
+ /**
67
+ * Reads how many replies this shell's stdout writer dropped because the pipe
68
+ * stopped draining. `gp_diagnose` surfaces it so a dropped reply is visible
69
+ * in-band rather than only in the log channel; it is wired from `index.ts`,
70
+ * where the `DrainAwareWriter` that counts them lives.
71
+ */
72
+ stdoutDroppedFrames?: () => number;
66
73
  }
67
74
  export declare class McpServer {
68
75
  private readonly deps;
@@ -103,7 +110,7 @@ export declare class McpServer {
103
110
  * that back from is this trail (`gp_recent_reports`). A sink that recorded into
104
111
  * a second session would look fixed and change nothing.
105
112
  */
106
- export declare function createTrackedMcpServer(engine: EngineJsonRpcClient, report: (note: string) => void): {
113
+ export declare function createTrackedMcpServer(engine: EngineJsonRpcClient, report: (note: string) => void, stdoutDroppedFrames?: () => number): {
107
114
  server: McpServer;
108
115
  session: EvidenceAuditSession;
109
116
  };
@@ -1730,7 +1730,7 @@ function daemonRestartNotice(filePath) {
1730
1730
  return `Saved in ${filePath}, the projects file under the home record this user's state root defaults to. If this is the root the service was started on \u2014 which is not knowable from here \u2014 then the running background service loaded that file once when it started, so gp_attach will keep returning GP_E_NOT_FOUND for this projectId until the service restarts: ${restartCommand()}. ${rootDiscriminator()}`;
1731
1731
  }
1732
1732
  function rootDiscriminator() {
1733
- return "Tell the two cases apart with the service's own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is one thing this shell cannot see and will not guess from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the socket reports no state root. Read it instead of deriving it \u2014 `launchctl print gui/" + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
1733
+ return 'Tell the two cases apart with the service\'s own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is not something this shell guesses from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the daemon reports the root it resolved \u2014 `hello` names the state root under its `stateRoot` key. A daemon predating 1.15.0 omits the key, and an omitted key means "that daemon does not report it", never "so it must be the home default": for those binaries the discriminator below is the only read. Cross-check either way \u2014 `launchctl print gui/' + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
1734
1734
  }
1735
1735
  function restartCommand() {
1736
1736
  return `run \`launchctl kickstart -k gui/${currentUid()}/com.glasspane.daemon\` \u2014 if that reports the service was not found, the launchd job was never bootstrapped, so ${daemonUnreachableRemedy()}`;
@@ -2054,7 +2054,7 @@ var TOOL_SPECS = [
2054
2054
  additionalProperties: false,
2055
2055
  properties: {
2056
2056
  maxDepth: { type: "integer", minimum: 1, maximum: 10 },
2057
- role: { type: "string", maxLength: SELECTOR_MAX_LENGTH }
2057
+ role: { type: "string", minLength: 1, maxLength: SELECTOR_MAX_LENGTH }
2058
2058
  }
2059
2059
  },
2060
2060
  validate: zodBridge(ObserveArgs)
@@ -2120,7 +2120,8 @@ var TOOL_SPECS = [
2120
2120
  }
2121
2121
  },
2122
2122
  validate: zodBridge(CaptureViewArgs),
2123
- execute: captureView
2123
+ execute: captureView,
2124
+ reachesDaemon: true
2124
2125
  },
2125
2126
  {
2126
2127
  name: "gp_diagnose",
@@ -2299,7 +2300,7 @@ var TOOL_SPECS = [
2299
2300
  execute: projectGetTool
2300
2301
  }
2301
2302
  ];
2302
- var DAEMON_FORWARDED_METHODS = TOOL_SPECS.filter((spec) => spec.execute === void 0).map((spec) => spec.engineMethod);
2303
+ var DAEMON_FORWARDED_METHODS = TOOL_SPECS.filter((spec) => spec.execute === void 0 || spec.reachesDaemon === true).map((spec) => spec.engineMethod);
2303
2304
  var TOOL_BY_NAME = new Map(
2304
2305
  TOOL_SPECS.map((spec) => [spec.name, spec])
2305
2306
  );
@@ -2699,6 +2700,10 @@ function renderTrailId(id, format) {
2699
2700
  function renderTrailIds(ids, format) {
2700
2701
  return ids.map((id) => renderTrailId(id, format)).join(", ");
2701
2702
  }
2703
+ function renderErrorLine(text, format) {
2704
+ const stripped = text.replace(/[\u0000-\u001f\u007f]/g, "\xB7");
2705
+ return format === "html" ? escapeHTML(stripped) : stripped.replace(/[`[\]<>#*\\]/g, "\xB7");
2706
+ }
2702
2707
  function describeFetchFailure(error) {
2703
2708
  if (error instanceof EngineCallError) {
2704
2709
  return formatToolErrorShape(error.toBody());
@@ -2708,7 +2713,11 @@ function describeFetchFailure(error) {
2708
2713
  const [first] = mapped.content;
2709
2714
  return first !== void 0 && first.type === "text" ? first.text : "the evidence read failed";
2710
2715
  }
2711
- return `${GP_E_INTERNAL}: ${String(error)}`;
2716
+ return formatToolError(
2717
+ GP_E_INTERNAL,
2718
+ String(error),
2719
+ "a fetch inside gp_recent_reports failed for a reason that is neither a daemon error frame nor an evidence-shape refusal, so this shell cannot name the cause from here. Read that operation on its own with gp_last_evidence {operationId} \u2014 it answers inside its own deadline and reports the fault by itself \u2014 or ask again with a smaller limit. Do not re-run gp_act to regenerate what the trail already holds"
2720
+ );
2712
2721
  }
2713
2722
  async function recentReports(args, context) {
2714
2723
  const argv = args;
@@ -2791,7 +2800,11 @@ async function recentReports(args, context) {
2791
2800
  }
2792
2801
  if (packs.length === 0 && hardFailure !== null) {
2793
2802
  const [first] = hardFailure.mapped.content;
2794
- const base = first !== void 0 && first.type === "text" ? first.text : `${GP_E_INTERNAL}: the evidence read failed`;
2803
+ const base = first !== void 0 && first.type === "text" ? first.text : formatToolError(
2804
+ GP_E_INTERNAL,
2805
+ "the evidence read failed",
2806
+ "the fetch mapped to an answer this shell could not render as text, so no more than the failure itself is known here. Read the operation on its own with gp_last_evidence {operationId}, which reports its own fault with a remedy, or ask again with a smaller limit. Do not re-run gp_act to regenerate what the trail already holds"
2807
+ );
2795
2808
  const extras = [];
2796
2809
  if (notFetched.length > 0) {
2797
2810
  extras.push(`${notFetched.length} further ${notFetched.length === 1 ? "entry" : "entries"} never fetched: ` + renderTrailIds(notFetched, format));
@@ -2834,7 +2847,7 @@ async function recentReports(args, context) {
2834
2847
  const skipNote = skipped.length === 0 ? "" : note("gp-skipped", `skipped ${skipped.length} unreachable ${skipped.length === 1 ? "entry" : "entries"}: ${renderTrailIds(skipped, format)}`);
2835
2848
  const unfetchedNote = notFetched.length === 0 ? "" : format === "html" ? `<p class="gp-not-fetched">not fetched within this report's ${RECENT_REPORTS_BUDGET_MS}ms wait: ${notFetched.length} ${notFetched.length === 1 ? "entry" : "entries"} left unanswered: ${renderTrailIds(notFetched, format)}. Fetch one by id with gp_last_evidence, or ask again with a smaller limit</p>` : `> not fetched within this report's ${RECENT_REPORTS_BUDGET_MS}ms wait: ${notFetched.length} ${notFetched.length === 1 ? "entry" : "entries"} left unanswered: ${renderTrailIds(notFetched, format)}. Fetch one by id with gp_last_evidence, or ask again with a smaller limit`;
2836
2849
  const rejectNote = rejected.length === 0 ? "" : note("gp-rejected", `${rejectedClause(rejected.length)}${renderTrailIds(rejected, format)}. They were not sent, so nothing was lost by their absence here: gp_last_evidence validates an id the same way before it asks`);
2837
- const failureNote = hardFailure === null ? "" : note("gp-fetch-failed", `the report stops here: ${renderTrailId(hardFailure.id, format)} failed with ${renderForeign(hardFailure.line, format)}. The ${packs.length} ${packs.length === 1 ? "pack" : "packs"} above were fetched and are rendered; read the failed one on its own with gp_last_evidence {operationId}. Do not re-run gp_act to regenerate what the trail already holds`);
2850
+ const failureNote = hardFailure === null ? "" : note("gp-fetch-failed", `the report stops here: ${renderTrailId(hardFailure.id, format)} failed with ${renderErrorLine(hardFailure.line, format)}. The ${packs.length} ${packs.length === 1 ? "pack" : "packs"} above were fetched and are rendered; read the failed one on its own with gp_last_evidence {operationId}. Do not re-run gp_act to regenerate what the trail already holds`);
2838
2851
  const separator = format === "html" ? "\n<hr>\n" : "\n\n---\n";
2839
2852
  const body = packs.map(({ pack }) => render2(pack, void 0)).join(separator);
2840
2853
  const sections2 = [header];
@@ -1720,7 +1720,7 @@ function daemonRestartNotice(filePath) {
1720
1720
  return `Saved in ${filePath}, the projects file under the home record this user's state root defaults to. If this is the root the service was started on \u2014 which is not knowable from here \u2014 then the running background service loaded that file once when it started, so gp_attach will keep returning GP_E_NOT_FOUND for this projectId until the service restarts: ${restartCommand()}. ${rootDiscriminator()}`;
1721
1721
  }
1722
1722
  function rootDiscriminator() {
1723
- return "Tell the two cases apart with the service's own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is one thing this shell cannot see and will not guess from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the socket reports no state root. Read it instead of deriving it \u2014 `launchctl print gui/" + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
1723
+ return 'Tell the two cases apart with the service\'s own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is not something this shell guesses from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the daemon reports the root it resolved \u2014 `hello` names the state root under its `stateRoot` key. A daemon predating 1.15.0 omits the key, and an omitted key means "that daemon does not report it", never "so it must be the home default": for those binaries the discriminator below is the only read. Cross-check either way \u2014 `launchctl print gui/' + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
1724
1724
  }
1725
1725
  function restartCommand() {
1726
1726
  return `run \`launchctl kickstart -k gui/${currentUid()}/com.glasspane.daemon\` \u2014 if that reports the service was not found, the launchd job was never bootstrapped, so ${daemonUnreachableRemedy()}`;
@@ -2044,7 +2044,7 @@ var TOOL_SPECS = [
2044
2044
  additionalProperties: false,
2045
2045
  properties: {
2046
2046
  maxDepth: { type: "integer", minimum: 1, maximum: 10 },
2047
- role: { type: "string", maxLength: SELECTOR_MAX_LENGTH }
2047
+ role: { type: "string", minLength: 1, maxLength: SELECTOR_MAX_LENGTH }
2048
2048
  }
2049
2049
  },
2050
2050
  validate: zodBridge(ObserveArgs)
@@ -2110,7 +2110,8 @@ var TOOL_SPECS = [
2110
2110
  }
2111
2111
  },
2112
2112
  validate: zodBridge(CaptureViewArgs),
2113
- execute: captureView
2113
+ execute: captureView,
2114
+ reachesDaemon: true
2114
2115
  },
2115
2116
  {
2116
2117
  name: "gp_diagnose",
@@ -2289,7 +2290,7 @@ var TOOL_SPECS = [
2289
2290
  execute: projectGetTool
2290
2291
  }
2291
2292
  ];
2292
- var DAEMON_FORWARDED_METHODS = TOOL_SPECS.filter((spec) => spec.execute === void 0).map((spec) => spec.engineMethod);
2293
+ var DAEMON_FORWARDED_METHODS = TOOL_SPECS.filter((spec) => spec.execute === void 0 || spec.reachesDaemon === true).map((spec) => spec.engineMethod);
2293
2294
  var TOOL_BY_NAME = new Map(
2294
2295
  TOOL_SPECS.map((spec) => [spec.name, spec])
2295
2296
  );
@@ -2689,6 +2690,10 @@ function renderTrailId(id, format) {
2689
2690
  function renderTrailIds(ids, format) {
2690
2691
  return ids.map((id) => renderTrailId(id, format)).join(", ");
2691
2692
  }
2693
+ function renderErrorLine(text, format) {
2694
+ const stripped = text.replace(/[\u0000-\u001f\u007f]/g, "\xB7");
2695
+ return format === "html" ? escapeHTML(stripped) : stripped.replace(/[`[\]<>#*\\]/g, "\xB7");
2696
+ }
2692
2697
  function describeFetchFailure(error) {
2693
2698
  if (error instanceof EngineCallError) {
2694
2699
  return formatToolErrorShape(error.toBody());
@@ -2698,7 +2703,11 @@ function describeFetchFailure(error) {
2698
2703
  const [first] = mapped.content;
2699
2704
  return first !== void 0 && first.type === "text" ? first.text : "the evidence read failed";
2700
2705
  }
2701
- return `${GP_E_INTERNAL}: ${String(error)}`;
2706
+ return formatToolError(
2707
+ GP_E_INTERNAL,
2708
+ String(error),
2709
+ "a fetch inside gp_recent_reports failed for a reason that is neither a daemon error frame nor an evidence-shape refusal, so this shell cannot name the cause from here. Read that operation on its own with gp_last_evidence {operationId} \u2014 it answers inside its own deadline and reports the fault by itself \u2014 or ask again with a smaller limit. Do not re-run gp_act to regenerate what the trail already holds"
2710
+ );
2702
2711
  }
2703
2712
  async function recentReports(args, context) {
2704
2713
  const argv = args;
@@ -2781,7 +2790,11 @@ async function recentReports(args, context) {
2781
2790
  }
2782
2791
  if (packs.length === 0 && hardFailure !== null) {
2783
2792
  const [first] = hardFailure.mapped.content;
2784
- const base = first !== void 0 && first.type === "text" ? first.text : `${GP_E_INTERNAL}: the evidence read failed`;
2793
+ const base = first !== void 0 && first.type === "text" ? first.text : formatToolError(
2794
+ GP_E_INTERNAL,
2795
+ "the evidence read failed",
2796
+ "the fetch mapped to an answer this shell could not render as text, so no more than the failure itself is known here. Read the operation on its own with gp_last_evidence {operationId}, which reports its own fault with a remedy, or ask again with a smaller limit. Do not re-run gp_act to regenerate what the trail already holds"
2797
+ );
2785
2798
  const extras = [];
2786
2799
  if (notFetched.length > 0) {
2787
2800
  extras.push(`${notFetched.length} further ${notFetched.length === 1 ? "entry" : "entries"} never fetched: ` + renderTrailIds(notFetched, format));
@@ -2824,7 +2837,7 @@ async function recentReports(args, context) {
2824
2837
  const skipNote = skipped.length === 0 ? "" : note("gp-skipped", `skipped ${skipped.length} unreachable ${skipped.length === 1 ? "entry" : "entries"}: ${renderTrailIds(skipped, format)}`);
2825
2838
  const unfetchedNote = notFetched.length === 0 ? "" : format === "html" ? `<p class="gp-not-fetched">not fetched within this report's ${RECENT_REPORTS_BUDGET_MS}ms wait: ${notFetched.length} ${notFetched.length === 1 ? "entry" : "entries"} left unanswered: ${renderTrailIds(notFetched, format)}. Fetch one by id with gp_last_evidence, or ask again with a smaller limit</p>` : `> not fetched within this report's ${RECENT_REPORTS_BUDGET_MS}ms wait: ${notFetched.length} ${notFetched.length === 1 ? "entry" : "entries"} left unanswered: ${renderTrailIds(notFetched, format)}. Fetch one by id with gp_last_evidence, or ask again with a smaller limit`;
2826
2839
  const rejectNote = rejected.length === 0 ? "" : note("gp-rejected", `${rejectedClause(rejected.length)}${renderTrailIds(rejected, format)}. They were not sent, so nothing was lost by their absence here: gp_last_evidence validates an id the same way before it asks`);
2827
- const failureNote = hardFailure === null ? "" : note("gp-fetch-failed", `the report stops here: ${renderTrailId(hardFailure.id, format)} failed with ${renderForeign(hardFailure.line, format)}. The ${packs.length} ${packs.length === 1 ? "pack" : "packs"} above were fetched and are rendered; read the failed one on its own with gp_last_evidence {operationId}. Do not re-run gp_act to regenerate what the trail already holds`);
2840
+ const failureNote = hardFailure === null ? "" : note("gp-fetch-failed", `the report stops here: ${renderTrailId(hardFailure.id, format)} failed with ${renderErrorLine(hardFailure.line, format)}. The ${packs.length} ${packs.length === 1 ? "pack" : "packs"} above were fetched and are rendered; read the failed one on its own with gp_last_evidence {operationId}. Do not re-run gp_act to regenerate what the trail already holds`);
2828
2841
  const separator = format === "html" ? "\n<hr>\n" : "\n\n---\n";
2829
2842
  const body = packs.map(({ pack }) => render2(pack, void 0)).join(separator);
2830
2843
  const sections2 = [header];
package/dist/index.js CHANGED
@@ -780,19 +780,28 @@ var DrainAwareWriter = class {
780
780
  get failed() {
781
781
  return this.dead !== null;
782
782
  }
783
- /** How many replies this writer refused to queue. `gp_diagnose` reads it: a
784
- * silently-shrinking answer is the failure mode this ceiling replaces. */
783
+ /**
784
+ * How many replies this writer refused to queue. `gp_diagnose` reads it and
785
+ * surfaces it in-band (a reply dropped to a stalled stdout otherwise has no
786
+ * reader outside the logs): a silently-shrinking answer is the failure mode this
787
+ * ceiling replaces.
788
+ */
785
789
  get droppedFrames() {
786
790
  return this.dropped;
787
791
  }
788
- /** @param text already framed, including its trailing newline. */
789
- async write(text) {
792
+ /**
793
+ * @param text already framed, including its trailing newline.
794
+ * @param requestId the JSON-RPC id the frame answers, so a drop names *which*
795
+ * reply the agent lost rather than only that one went missing.
796
+ */
797
+ async write(text, requestId) {
790
798
  if (this.failed) {
791
799
  return false;
792
800
  }
793
801
  if (this.sink.writableLength >= this.backlogLimitBytes) {
794
802
  this.dropped += 1;
795
- this.report(`reply dropped, not queued: stdout already holds ${this.sink.writableLength} bytes past the ${this.backlogLimitBytes} ceiling \u2014 the reader on the other end stopped consuming, so this frame (and ${this.dropped - 1} earlier one(s)) will not be delivered`);
803
+ const who = requestId === void 0 || requestId === null ? "a reply" : `reply ${JSON.stringify(requestId)}`;
804
+ this.report(`${who} dropped, not queued: stdout already holds ${this.sink.writableLength} bytes past the ${this.backlogLimitBytes} ceiling \u2014 the reader on the other end stopped consuming, so this frame (and ${this.dropped - 1} earlier one(s)) will not be delivered`);
796
805
  return false;
797
806
  }
798
807
  let flowing = true;
@@ -2143,7 +2152,7 @@ function daemonRestartNotice(filePath) {
2143
2152
  return `Saved in ${filePath}, the projects file under the home record this user's state root defaults to. If this is the root the service was started on \u2014 which is not knowable from here \u2014 then the running background service loaded that file once when it started, so gp_attach will keep returning GP_E_NOT_FOUND for this projectId until the service restarts: ${restartCommand()}. ${rootDiscriminator()}`;
2144
2153
  }
2145
2154
  function rootDiscriminator() {
2146
- return "Tell the two cases apart with the service's own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is one thing this shell cannot see and will not guess from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the socket reports no state root. Read it instead of deriving it \u2014 `launchctl print gui/" + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
2155
+ return 'Tell the two cases apart with the service\'s own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is not something this shell guesses from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the daemon reports the root it resolved \u2014 `hello` names the state root under its `stateRoot` key. A daemon predating 1.15.0 omits the key, and an omitted key means "that daemon does not report it", never "so it must be the home default": for those binaries the discriminator below is the only read. Cross-check either way \u2014 `launchctl print gui/' + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
2147
2156
  }
2148
2157
  function restartCommand() {
2149
2158
  return `run \`launchctl kickstart -k gui/${currentUid()}/com.glasspane.daemon\` \u2014 if that reports the service was not found, the launchd job was never bootstrapped, so ${daemonUnreachableRemedy()}`;
@@ -2467,7 +2476,7 @@ var TOOL_SPECS = [
2467
2476
  additionalProperties: false,
2468
2477
  properties: {
2469
2478
  maxDepth: { type: "integer", minimum: 1, maximum: 10 },
2470
- role: { type: "string", maxLength: SELECTOR_MAX_LENGTH }
2479
+ role: { type: "string", minLength: 1, maxLength: SELECTOR_MAX_LENGTH }
2471
2480
  }
2472
2481
  },
2473
2482
  validate: zodBridge(ObserveArgs)
@@ -2533,7 +2542,8 @@ var TOOL_SPECS = [
2533
2542
  }
2534
2543
  },
2535
2544
  validate: zodBridge(CaptureViewArgs),
2536
- execute: captureView
2545
+ execute: captureView,
2546
+ reachesDaemon: true
2537
2547
  },
2538
2548
  {
2539
2549
  name: "gp_diagnose",
@@ -2712,7 +2722,7 @@ var TOOL_SPECS = [
2712
2722
  execute: projectGetTool
2713
2723
  }
2714
2724
  ];
2715
- var DAEMON_FORWARDED_METHODS = TOOL_SPECS.filter((spec) => spec.execute === void 0).map((spec) => spec.engineMethod);
2725
+ var DAEMON_FORWARDED_METHODS = TOOL_SPECS.filter((spec) => spec.execute === void 0 || spec.reachesDaemon === true).map((spec) => spec.engineMethod);
2716
2726
  var TOOL_BY_NAME = new Map(
2717
2727
  TOOL_SPECS.map((spec) => [spec.name, spec])
2718
2728
  );
@@ -2895,7 +2905,7 @@ function shellFaultRemedy(engineMethod) {
2895
2905
  }
2896
2906
  return `this is a defect in this shell, not an answer from the engine. '${engineMethod}' is not one of the methods this shell classifies as unsafe to repeat (the no-replay set is act/restore/attach, plus snapshot, whose re-issue writes daemon state), so sending this tool again is a real option rather than a side effect: do that once. If the fault repeats, ${readRoute}. ${reportRoute}`;
2897
2907
  }
2898
- async function executeTool(spec, args, engine, session) {
2908
+ async function executeTool(spec, args, engine, session, stdoutDroppedFrames) {
2899
2909
  if (!session) {
2900
2910
  return {
2901
2911
  content: [{ type: "text", text: formatToolError(
@@ -2919,16 +2929,16 @@ async function executeTool(spec, args, engine, session) {
2919
2929
  }
2920
2930
  const advised = engineWithPayloadAdvice(engine, spec, checked.value);
2921
2931
  if (!trailScopedTool(spec)) {
2922
- return runValidatedTool(spec, checked.value, advised, session, INERT_TRAIL_TURN);
2932
+ return runValidatedTool(spec, checked.value, advised, session, INERT_TRAIL_TURN, stdoutDroppedFrames);
2923
2933
  }
2924
2934
  const turn = session.claimTrailTurn();
2925
2935
  try {
2926
- return await runValidatedTool(spec, checked.value, advised, session, turn);
2936
+ return await runValidatedTool(spec, checked.value, advised, session, turn, stdoutDroppedFrames);
2927
2937
  } finally {
2928
2938
  turn.release();
2929
2939
  }
2930
2940
  }
2931
- async function runValidatedTool(spec, value, engine, session, turn) {
2941
+ async function runValidatedTool(spec, value, engine, session, turn, stdoutDroppedFrames) {
2932
2942
  if (spec.execute !== void 0) {
2933
2943
  return spec.execute(value, { engine, session, turn });
2934
2944
  }
@@ -2955,6 +2965,15 @@ async function runValidatedTool(spec, value, engine, session, turn) {
2955
2965
  ${caBundleDiagnosis(reading.ca, updaterEnableCommand(reading.runtime))}
2956
2966
  ${runtimeSelfDiagnosis(reading.runtime, reading.stateFile)}` : reading.summary
2957
2967
  });
2968
+ if (stdoutDroppedFrames !== void 0) {
2969
+ const dropped = stdoutDroppedFrames();
2970
+ if (dropped > 0) {
2971
+ content.push({
2972
+ type: "text",
2973
+ text: `stdout backlog: this shell dropped ${dropped} ${dropped === 1 ? "reply" : "replies"} since startup because the reader on the other end stopped consuming them, so an answer you expected to receive may never have been written. The operation itself is unaffected \u2014 this is the byte stream, not the daemon. Drain stdout (close whatever is holding the pipe), then re-read the specific operation with gp_last_evidence {operationId}; do not re-run gp_act to regenerate work the trail already records`
2974
+ });
2975
+ }
2976
+ }
2958
2977
  }
2959
2978
  return {
2960
2979
  content,
@@ -3357,6 +3376,10 @@ function renderTrailId(id, format) {
3357
3376
  function renderTrailIds(ids, format) {
3358
3377
  return ids.map((id) => renderTrailId(id, format)).join(", ");
3359
3378
  }
3379
+ function renderErrorLine(text, format) {
3380
+ const stripped = text.replace(/[\u0000-\u001f\u007f]/g, "\xB7");
3381
+ return format === "html" ? escapeHTML(stripped) : stripped.replace(/[`[\]<>#*\\]/g, "\xB7");
3382
+ }
3360
3383
  function describeFetchFailure(error) {
3361
3384
  if (error instanceof EngineCallError) {
3362
3385
  return formatToolErrorShape(error.toBody());
@@ -3366,7 +3389,11 @@ function describeFetchFailure(error) {
3366
3389
  const [first] = mapped.content;
3367
3390
  return first !== void 0 && first.type === "text" ? first.text : "the evidence read failed";
3368
3391
  }
3369
- return `${GP_E_INTERNAL}: ${String(error)}`;
3392
+ return formatToolError(
3393
+ GP_E_INTERNAL,
3394
+ String(error),
3395
+ "a fetch inside gp_recent_reports failed for a reason that is neither a daemon error frame nor an evidence-shape refusal, so this shell cannot name the cause from here. Read that operation on its own with gp_last_evidence {operationId} \u2014 it answers inside its own deadline and reports the fault by itself \u2014 or ask again with a smaller limit. Do not re-run gp_act to regenerate what the trail already holds"
3396
+ );
3370
3397
  }
3371
3398
  async function recentReports(args, context) {
3372
3399
  const argv = args;
@@ -3449,7 +3476,11 @@ async function recentReports(args, context) {
3449
3476
  }
3450
3477
  if (packs.length === 0 && hardFailure !== null) {
3451
3478
  const [first] = hardFailure.mapped.content;
3452
- const base = first !== void 0 && first.type === "text" ? first.text : `${GP_E_INTERNAL}: the evidence read failed`;
3479
+ const base = first !== void 0 && first.type === "text" ? first.text : formatToolError(
3480
+ GP_E_INTERNAL,
3481
+ "the evidence read failed",
3482
+ "the fetch mapped to an answer this shell could not render as text, so no more than the failure itself is known here. Read the operation on its own with gp_last_evidence {operationId}, which reports its own fault with a remedy, or ask again with a smaller limit. Do not re-run gp_act to regenerate what the trail already holds"
3483
+ );
3453
3484
  const extras = [];
3454
3485
  if (notFetched.length > 0) {
3455
3486
  extras.push(`${notFetched.length} further ${notFetched.length === 1 ? "entry" : "entries"} never fetched: ` + renderTrailIds(notFetched, format));
@@ -3492,7 +3523,7 @@ async function recentReports(args, context) {
3492
3523
  const skipNote = skipped.length === 0 ? "" : note("gp-skipped", `skipped ${skipped.length} unreachable ${skipped.length === 1 ? "entry" : "entries"}: ${renderTrailIds(skipped, format)}`);
3493
3524
  const unfetchedNote = notFetched.length === 0 ? "" : format === "html" ? `<p class="gp-not-fetched">not fetched within this report's ${RECENT_REPORTS_BUDGET_MS}ms wait: ${notFetched.length} ${notFetched.length === 1 ? "entry" : "entries"} left unanswered: ${renderTrailIds(notFetched, format)}. Fetch one by id with gp_last_evidence, or ask again with a smaller limit</p>` : `> not fetched within this report's ${RECENT_REPORTS_BUDGET_MS}ms wait: ${notFetched.length} ${notFetched.length === 1 ? "entry" : "entries"} left unanswered: ${renderTrailIds(notFetched, format)}. Fetch one by id with gp_last_evidence, or ask again with a smaller limit`;
3494
3525
  const rejectNote = rejected.length === 0 ? "" : note("gp-rejected", `${rejectedClause(rejected.length)}${renderTrailIds(rejected, format)}. They were not sent, so nothing was lost by their absence here: gp_last_evidence validates an id the same way before it asks`);
3495
- const failureNote = hardFailure === null ? "" : note("gp-fetch-failed", `the report stops here: ${renderTrailId(hardFailure.id, format)} failed with ${renderForeign(hardFailure.line, format)}. The ${packs.length} ${packs.length === 1 ? "pack" : "packs"} above were fetched and are rendered; read the failed one on its own with gp_last_evidence {operationId}. Do not re-run gp_act to regenerate what the trail already holds`);
3526
+ const failureNote = hardFailure === null ? "" : note("gp-fetch-failed", `the report stops here: ${renderTrailId(hardFailure.id, format)} failed with ${renderErrorLine(hardFailure.line, format)}. The ${packs.length} ${packs.length === 1 ? "pack" : "packs"} above were fetched and are rendered; read the failed one on its own with gp_last_evidence {operationId}. Do not re-run gp_act to regenerate what the trail already holds`);
3496
3527
  const separator = format === "html" ? "\n<hr>\n" : "\n\n---\n";
3497
3528
  const body = packs.map(({ pack }) => render(pack, void 0)).join(separator);
3498
3529
  const sections2 = [header];
@@ -3793,7 +3824,7 @@ var SUPPORTED_PROTOCOL_VERSIONS = [
3793
3824
  "2025-03-26",
3794
3825
  MCP_PROTOCOL_VERSION
3795
3826
  ];
3796
- var SERVER_INFO = { name: "glasspane-mcp", version: "1.14.0" };
3827
+ var SERVER_INFO = { name: "glasspane-mcp", version: "1.15.0" };
3797
3828
  var PARSE_ERROR = -32700;
3798
3829
  var INVALID_REQUEST = -32600;
3799
3830
  var METHOD_NOT_FOUND = -32601;
@@ -3912,7 +3943,13 @@ var McpServer = class {
3912
3943
  if (!spec) {
3913
3944
  return this.error(INVALID_PARAMS, `Unknown tool: ${name}`, id);
3914
3945
  }
3915
- const outcome = await executeTool(spec, params.arguments ?? {}, this.deps.engine, this.session);
3946
+ const outcome = await executeTool(
3947
+ spec,
3948
+ params.arguments ?? {},
3949
+ this.deps.engine,
3950
+ this.session,
3951
+ this.deps.stdoutDroppedFrames
3952
+ );
3916
3953
  return this.result(id, { content: outcome.content, isError: outcome.isError });
3917
3954
  }
3918
3955
  asParams(rawParams) {
@@ -3935,7 +3972,7 @@ var McpServer = class {
3935
3972
  return { jsonrpc: JSONRPC, id, error: { code, message } };
3936
3973
  }
3937
3974
  };
3938
- function createTrackedMcpServer(engine, report) {
3975
+ function createTrackedMcpServer(engine, report, stdoutDroppedFrames) {
3939
3976
  const session = new EvidenceAuditSession();
3940
3977
  engine.onLateReply(({ method, result, correlation, attribution }) => {
3941
3978
  if (correlation !== void 0 && correlation !== session.generation) {
@@ -3969,7 +4006,7 @@ function createTrackedMcpServer(engine, report) {
3969
4006
  }
3970
4007
  );
3971
4008
  });
3972
- return { server: new McpServer({ engine, session, report }), session };
4009
+ return { server: new McpServer({ engine, session, report, stdoutDroppedFrames }), session };
3973
4010
  }
3974
4011
  function describeAttach(result) {
3975
4012
  if (typeof result !== "object" || result === null) {
@@ -4113,12 +4150,13 @@ function main() {
4113
4150
  const replies = new DrainAwareWriter(process2.stdout, logNote, DRAIN_WAIT_MS, () => shutdown());
4114
4151
  const queue = new OrderedReplyQueue((error) => logNote(`reply failed: ${String(error)}`));
4115
4152
  engine.onEngineNote(logNote);
4116
- const server = createTrackedMcpServer(engine, logNote).server;
4153
+ const server = createTrackedMcpServer(engine, logNote, () => replies.droppedFrames).server;
4117
4154
  const writeResponse = async (response) => {
4118
4155
  if (response === null) {
4119
4156
  return;
4120
4157
  }
4121
- await replies.write(canonicalJson(response) + "\n");
4158
+ const id = response.id;
4159
+ await replies.write(canonicalJson(response) + "\n", id);
4122
4160
  };
4123
4161
  const reader = new LineReader({
4124
4162
  onLine: (line) => {
@@ -4138,7 +4176,7 @@ function main() {
4138
4176
  queue.push(async () => shutdown());
4139
4177
  });
4140
4178
  process2.stdin.on("close", () => queue.push(async () => shutdown()));
4141
- process2.on("SIGINT", shutdown);
4142
- process2.on("SIGTERM", shutdown);
4179
+ process2.on("SIGINT", () => queue.push(async () => shutdown()));
4180
+ process2.on("SIGTERM", () => queue.push(async () => shutdown()));
4143
4181
  }
4144
4182
  main();
package/dist/io.d.ts CHANGED
@@ -155,11 +155,19 @@ export declare class DrainAwareWriter {
155
155
  private dropped;
156
156
  constructor(sink: Writable, report: (note: string) => void, drainWaitMs: number, onDead: (error: Error) => void, backlogLimitBytes?: number);
157
157
  get failed(): boolean;
158
- /** How many replies this writer refused to queue. `gp_diagnose` reads it: a
159
- * silently-shrinking answer is the failure mode this ceiling replaces. */
158
+ /**
159
+ * How many replies this writer refused to queue. `gp_diagnose` reads it and
160
+ * surfaces it in-band (a reply dropped to a stalled stdout otherwise has no
161
+ * reader outside the logs): a silently-shrinking answer is the failure mode this
162
+ * ceiling replaces.
163
+ */
160
164
  get droppedFrames(): number;
161
- /** @param text already framed, including its trailing newline. */
162
- write(text: string): Promise<boolean>;
165
+ /**
166
+ * @param text already framed, including its trailing newline.
167
+ * @param requestId the JSON-RPC id the frame answers, so a drop names *which*
168
+ * reply the agent lost rather than only that one went missing.
169
+ */
170
+ write(text: string, requestId?: number | string | null): Promise<boolean>;
163
171
  private waitForDrain;
164
172
  }
165
173
  //# sourceMappingURL=io.d.ts.map
@@ -147,7 +147,8 @@ export declare function projectSet(args: ProjectSetArgs): ProjectSetResult;
147
147
  * with no state root. `glasspaned --state-dir <root>` makes it load
148
148
  * `<root>/projects.json` (`ProjectRegistry(stateRoot:)`, and every subcommand
149
149
  * resolves through the same root), and the socket cannot be asked which root the
150
- * running job chose — the method table is frozen and reports no state root. So a
150
+ * running job chose — since 1.15.0 the daemon says so in `hello`'s `stateRoot`, while a
151
+ * service still running an older binary does not. So a
151
152
  * notice that simply ordered a restart was telling an agent that restarting would
152
153
  * help in the one case where it cannot, and a notice that said "this is the file
153
154
  * the service loads" was naming a file the service may not load. Both branches
package/dist/tools.d.ts CHANGED
@@ -258,6 +258,15 @@ export interface ToolSpec {
258
258
  * packs and render them through the report generator.
259
259
  */
260
260
  execute?: (args: Record<string, unknown>, context: ToolExecuteContext) => Promise<ToolResult>;
261
+ /**
262
+ * True for a tool that has its own `execute` yet still puts a real daemon
263
+ * method on the wire (`gp_capture_view`'s executor calls `capture_view`), so
264
+ * `hello.capabilities` reconciliation must count its `engineMethod` as
265
+ * forwarded. Left unset on genuinely shell-only tools (`gp_recent_reports`,
266
+ * the project tools), whose `engineMethod` is a pseudo-method the daemon does
267
+ * not advertise and would answer with `methodNotFound`.
268
+ */
269
+ reachesDaemon?: true;
261
270
  }
262
271
  export interface ToolExecuteContext {
263
272
  engine: EngineJsonRpcClient;
@@ -294,6 +303,15 @@ export declare const TOOL_SPECS: readonly ToolSpec[];
294
303
  * (`engine-client.describeCapabilityDrift`): a hand-copied second truth is the
295
304
  * exact drift the reader exists to catch, and the gateway already derives its own
296
305
  * forwardable set from `execute === undefined` for the same reason.
306
+ *
307
+ * `execute === undefined` alone under-counts: `gp_capture_view` has its own
308
+ * executor, yet that executor calls `capture_view` on the wire, so the daemon
309
+ * advertises a method the agent *can* reach and the reconciliation would report
310
+ * as unreachable from MCP. `reachesDaemon` marks exactly those executor tools
311
+ * that still forward a real daemon method; `test/shell-2026-defects.test.mjs`
312
+ * cross-checks this set against both the daemon's advertised capability names and
313
+ * the literal `.call("…")` sites so neither a new omission nor a stale pseudo-
314
+ * method survives silently.
297
315
  */
298
316
  export declare const DAEMON_FORWARDED_METHODS: readonly string[];
299
317
  export declare const TOOL_BY_NAME: ReadonlyMap<string, ToolSpec>;
@@ -422,7 +440,14 @@ export declare function executeTool(spec: ToolSpec, args: unknown, engine: Engin
422
440
  * meaningful caller here is JavaScript (`dist` consumers, the smoke scripts, the
423
441
  * tests), where an omitted argument compiles forever and fails silently.
424
442
  */
425
- session: EvidenceAuditSession): Promise<ToolResult>;
443
+ session: EvidenceAuditSession,
444
+ /**
445
+ * Reads how many replies this shell's stdout writer dropped under backpressure,
446
+ * so `gp_diagnose` can surface a lost reply in-band. Optional: only `index.ts`
447
+ * owns the writer, and a caller without one (tests, the HTTP gateway) leaves it
448
+ * undefined and the tool stays silent.
449
+ */
450
+ stdoutDroppedFrames?: () => number): Promise<ToolResult>;
426
451
  /**
427
452
  * Strongly validates the engine's last_evidence result (§6.3) and returns the
428
453
  * parsed pack together with the schema label the archive *carried*. The engine
@@ -451,6 +476,14 @@ export declare function parseEvidenceFrame(raw: unknown): {
451
476
  * byte-identical to the shared golden.
452
477
  */
453
478
  export declare function packForReport(pack: EvidencePack, measuredSchemaVersion: string): EvidencePackReportView;
479
+ /**
480
+ * One fetch failure, in the one line a partial report has room for.
481
+ * Exported for `test/shell-2026-defects.test.mjs`, which pins the
482
+ * `{code, message, remedy}` shape of the fallback arm: that arm is what a
483
+ * non-daemon, non-shape error reaches, and a two-field string there is an error
484
+ * frame leaving the shell with no next move.
485
+ */
486
+ export declare function describeFetchFailure(error: unknown): string;
454
487
  export type { Selector, Action, AssertionProperty };
455
488
  /**
456
489
  * The engine view handed to one tool: the same client, with one kind of error's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-mcp",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "GlassPane MCP stdio shell: JSON-RPC 2.0 over stdio bridging an AI agent to the glasspane engine daemon",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.iterate.dev/convergence-round-state-0.1.json",
4
+ "title": "ConvergenceRoundState",
5
+ "description": "What a caller states about the round that just finished, which the convergence rules turn into an action (5.7 R40 scenario 4). Every field is a verdict somebody else produced; this contract moves none of them. NOTE ON SOURCE OF TRUTH: the field constraints are duplicated from `src/convergence.ts`, which is the definition; this file mirrors it. The ordering that decides which verdict wins (breaker, then budget, then errored check, then findings, then evidence, then invariants, then converged) is arithmetic in the validator and cannot be expressed in JSON Schema — a schema-only implementation must re-implement `convergenceDecision` and be tested against the cases in `fixtures/convergence.ok-01.json`.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["round", "maxRounds", "invariantState", "openFindings", "evidenceComplete", "breakerLevel"],
9
+ "properties": {
10
+ "schemaVersion": {
11
+ "const": "iterate.convergence/0.1"
12
+ },
13
+ "round": {
14
+ "type": "integer",
15
+ "minimum": 1
16
+ },
17
+ "maxRounds": {
18
+ "type": "integer",
19
+ "minimum": 1
20
+ },
21
+ "invariantState": {
22
+ "enum": ["pass", "fail", "error", "unmeasured"]
23
+ },
24
+ "openFindings": {
25
+ "type": "integer",
26
+ "minimum": 0
27
+ },
28
+ "evidenceComplete": {
29
+ "type": "boolean"
30
+ },
31
+ "breakerLevel": {
32
+ "type": "integer",
33
+ "minimum": 0,
34
+ "maximum": 3
35
+ }
36
+ }
37
+ }
@@ -40,6 +40,26 @@
40
40
  "createdAt": {
41
41
  "type": "string",
42
42
  "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
43
+ },
44
+ "kind": {
45
+ "type": "string",
46
+ "pattern": "^[a-z][a-z0-9_]{0,31}$",
47
+ "description": "What sort of decision this line records, in the recording ecosystem's own vocabulary (iterate uses round_start / review_result / atomic_fix / …, the GlassPane fork records tool executions). Deliberately NOT an enum: the kernel does not own either vocabulary, and enumerating them would make every new producer kind a kernel release. See the module doc in src/decision-log-entry.ts."
48
+ },
49
+ "round": {
50
+ "type": "integer",
51
+ "minimum": 0,
52
+ "description": "Loop position the decision belongs to, when the producer counts rounds. Optional because a one-off tool execution is not part of a round."
53
+ },
54
+ "dimension": {
55
+ "type": "string",
56
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
57
+ "maxLength": 64,
58
+ "description": "The dimension this decision was serving, using the same id rule as dimension-context (DIMENSION_ID_PATTERN / DIMENSION_ID_MAX_LENGTH, imported not restated). This is what lets a recorded decision count toward 'N/M dimensions verified' from either ecosystem."
59
+ },
60
+ "payload": {
61
+ "type": "object",
62
+ "description": "Structured detail carried alongside the human-readable summary — the field the legacy 'data' object maps onto. NOTE A BOUND THE SCHEMA CANNOT STATE: the canonical-JSON form of this object is limited to PAYLOAD_MAX_CANONICAL_LENGTH (8192) characters by the TypeScript validator and the Python port, because an unbounded field in a hash-chained ledger lets a line's bytes and digest grow without limit. JSON Schema has no way to bound serialized length, so this is a documented divergence, pinned as a named case in kernel/test/decision-log-entry.test.mjs rather than silently allowed."
43
63
  }
44
64
  }
45
65
  }
@@ -197,16 +197,19 @@
197
197
  "role": {
198
198
  "type": "string",
199
199
  "minLength": 1,
200
- "maxLength": 512
200
+ "maxLength": 512,
201
+ "description": "What the target is, in the accessibility tree's own vocabulary. Never empty: the engine's matcher reads an empty role as 'no constraint stated', so an empty role stops naming what the assertion targets instead of narrowing it — and the daemon refuses it."
201
202
  },
202
203
  "title": {
203
204
  "type": "string",
204
205
  "minLength": 1,
205
- "maxLength": 512
206
+ "maxLength": 512,
207
+ "description": "Optional display label. When present it is never empty, for the same reason as role: a report rendered from `title: \"\"` prints a missing label and reads as 'the tree had no title there'."
206
208
  },
207
209
  "identifier": {
208
210
  "type": "string",
209
- "maxLength": 512
211
+ "maxLength": 512,
212
+ "description": "Optional AXIdentifier. Deliberately has no minLength: no side rules on an empty identifier (the daemon has no guard for it), so the kernel does not invent one."
210
213
  }
211
214
  }
212
215
  },
@@ -0,0 +1,108 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.iterate.dev/invariant-report-0.1.json",
4
+ "title": "InvariantReport",
5
+ "description": "What the invariant runner observed when it checked a recipe's invariants (5.7 R40 scenario 1: the failure that has to reach the other harness). Whoever runs the checks produces this; whoever decides whether to re-verify reads it. A check kind of `refused` means the runner declined to execute a configured command because it carries shell metacharacters or is empty — it is a violation, so a report whose only failing check was refused is `fail`, never `error`. NOTE ON SOURCE OF TRUTH: the check-name and detail constraints are duplicated from `src/invariant-report.ts`, which is the definition; this file mirrors it. The outcome-vs-checks rule IS expressible here and is expressed twice on purpose: the `allOf` branches below and the zod `superRefine` must refuse the same reports, so a second implementation that only validates JSON Schema still cannot carry a pass over a failed check.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schemaVersion", "outcome", "checks"],
9
+ "properties": {
10
+ "schemaVersion": {
11
+ "const": "iterate.invariant-report/0.1"
12
+ },
13
+ "outcome": {
14
+ "enum": ["pass", "fail", "error"]
15
+ },
16
+ "checks": {
17
+ "type": "array",
18
+ "minItems": 1,
19
+ "maxItems": 256,
20
+ "items": {
21
+ "$ref": "#/$defs/invariantCheck"
22
+ }
23
+ },
24
+ "round": {
25
+ "type": "integer",
26
+ "minimum": 1
27
+ },
28
+ "durationMs": {
29
+ "type": "integer",
30
+ "minimum": 0
31
+ }
32
+ },
33
+ "allOf": [
34
+ {
35
+ "if": {
36
+ "properties": {
37
+ "outcome": {
38
+ "const": "pass"
39
+ }
40
+ },
41
+ "required": ["outcome"]
42
+ },
43
+ "then": {
44
+ "properties": {
45
+ "checks": {
46
+ "items": {
47
+ "properties": {
48
+ "ok": {
49
+ "const": true
50
+ }
51
+ },
52
+ "required": ["ok"]
53
+ }
54
+ }
55
+ }
56
+ }
57
+ },
58
+ {
59
+ "if": {
60
+ "properties": {
61
+ "outcome": {
62
+ "const": "fail"
63
+ }
64
+ },
65
+ "required": ["outcome"]
66
+ },
67
+ "then": {
68
+ "properties": {
69
+ "checks": {
70
+ "contains": {
71
+ "properties": {
72
+ "ok": {
73
+ "const": false
74
+ }
75
+ },
76
+ "required": ["ok"]
77
+ },
78
+ "minContains": 1
79
+ }
80
+ }
81
+ }
82
+ }
83
+ ],
84
+ "$defs": {
85
+ "invariantCheck": {
86
+ "type": "object",
87
+ "additionalProperties": false,
88
+ "required": ["name", "kind", "ok"],
89
+ "properties": {
90
+ "name": {
91
+ "type": "string",
92
+ "minLength": 1,
93
+ "maxLength": 256
94
+ },
95
+ "kind": {
96
+ "enum": ["ensure", "command", "refused"]
97
+ },
98
+ "ok": {
99
+ "type": "boolean"
100
+ },
101
+ "detail": {
102
+ "type": "string",
103
+ "maxLength": 4096
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.iterate.dev/provider-registration-0.1.json",
4
+ "title": "ProviderRegistration",
5
+ "description": "Metadata for one judgement producer, so both harnesses can consume the same provider through the same shape (5.7 R40 scenario 5). It declares who provides what and what they need; it never carries a judgement, which stays in the engine and the reviewers. NOTE ON SOURCE OF TRUTH: the field constraints are duplicated from `src/provider-registration.ts`, which is the definition; this file mirrors it. METHOD-LEVEL UNIQUENESS IS ENFORCED BY ZOD ONLY: `uniqueItems` compares whole objects, so two methods with the same name but different `requires` pass this schema and are refused by `ProviderRegistrationSchema`. The registry semantics (refusing to overwrite an id, no default provider, no fuzzy resolve) are functions in the validator, not schema features.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schemaVersion", "id", "kind", "version", "methods"],
9
+ "properties": {
10
+ "schemaVersion": {
11
+ "const": "iterate.provider/0.1"
12
+ },
13
+ "id": {
14
+ "type": "string",
15
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
16
+ "maxLength": 64
17
+ },
18
+ "kind": {
19
+ "enum": ["ae-diagnosis", "report-render", "evidence-source"]
20
+ },
21
+ "version": {
22
+ "type": "string",
23
+ "minLength": 1,
24
+ "maxLength": 64
25
+ },
26
+ "origin": {
27
+ "enum": ["kernel", "glasspane", "iterate", "plugin"]
28
+ },
29
+ "methods": {
30
+ "type": "array",
31
+ "minItems": 1,
32
+ "maxItems": 32,
33
+ "uniqueItems": true,
34
+ "items": {
35
+ "$ref": "#/$defs/providerMethod"
36
+ }
37
+ }
38
+ },
39
+ "$defs": {
40
+ "providerMethod": {
41
+ "type": "object",
42
+ "additionalProperties": false,
43
+ "required": ["name", "requires"],
44
+ "properties": {
45
+ "name": {
46
+ "type": "string",
47
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
48
+ "maxLength": 64
49
+ },
50
+ "requires": {
51
+ "type": "array",
52
+ "maxItems": 16,
53
+ "uniqueItems": true,
54
+ "items": {
55
+ "type": "string",
56
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
57
+ "maxLength": 64
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,89 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.iterate.dev/reverification-request-0.1.json",
4
+ "title": "ReverificationRequest",
5
+ "description": "The ask that comes out of a failed invariant report (5.7 R40 scenario 1): re-run these recipe steps, dimensions, or operations. It cites the failure by digest rather than embedding it, so the ledger line that records the re-verification can be checked against the ledger line that records the failure. NOTE ON SOURCE OF TRUTH: the field constraints are duplicated from `src/invariant-report.ts`, which is the definition; this file mirrors it. The 'an empty target set is not a re-verification' rule IS expressible here and is expressed twice on purpose, via `anyOf`, so a schema-only implementation refuses it too.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schemaVersion", "trigger", "targets"],
9
+ "properties": {
10
+ "schemaVersion": {
11
+ "const": "iterate.reverification/0.1"
12
+ },
13
+ "trigger": {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "required": ["kind", "reportDigest"],
17
+ "properties": {
18
+ "kind": {
19
+ "const": "invariant"
20
+ },
21
+ "reportDigest": {
22
+ "type": "string",
23
+ "pattern": "^iv_[0-9a-f]{64}$"
24
+ }
25
+ }
26
+ },
27
+ "targets": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "anyOf": [
31
+ {
32
+ "required": ["recipeSteps"]
33
+ },
34
+ {
35
+ "required": ["dimensions"]
36
+ },
37
+ {
38
+ "required": ["operationIds"]
39
+ }
40
+ ],
41
+ "properties": {
42
+ "recipeSteps": {
43
+ "type": "array",
44
+ "minItems": 1,
45
+ "maxItems": 64,
46
+ "items": {
47
+ "$ref": "#/$defs/recipeStep"
48
+ }
49
+ },
50
+ "dimensions": {
51
+ "type": "array",
52
+ "minItems": 1,
53
+ "maxItems": 64,
54
+ "uniqueItems": true,
55
+ "items": {
56
+ "type": "string",
57
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
58
+ "maxLength": 64
59
+ }
60
+ },
61
+ "operationIds": {
62
+ "type": "array",
63
+ "minItems": 1,
64
+ "maxItems": 64,
65
+ "uniqueItems": true,
66
+ "items": {
67
+ "type": "string",
68
+ "pattern": "^op_[0-9A-HJKMNP-TV-Z]{26}$"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ },
74
+ "$defs": {
75
+ "recipeStep": {
76
+ "type": "object",
77
+ "additionalProperties": false,
78
+ "required": ["kind", "params"],
79
+ "properties": {
80
+ "kind": {
81
+ "enum": ["act", "observe", "assert", "diagnose"]
82
+ },
83
+ "params": {
84
+ "type": "object"
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.iterate.dev/run-plan-0.1.json",
4
+ "title": "RunPlan",
5
+ "description": "Which review dimensions a run intends to cover, in planned order (5.7 R40 scenario 2: dimension-aware context compression needs a plan to be aware of). Whoever configures the run produces this; whoever renders coverage reads it. Nothing in the kernel reads a config file to make one. NOTE ON SOURCE OF TRUTH: the id/label/focus constraints are duplicated from `src/dimension-context.ts`, which has no JSON Schema of its own — until that gap is closed, the zod side is the definition and this file mirrors it. ID-LEVEL UNIQUENESS IS ENFORCED BY ZOD ONLY: `uniqueItems` compares whole objects, so two entries with the same id but different focus pass this schema and are refused by `RunPlanSchema`.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schemaVersion", "dimensions"],
9
+ "properties": {
10
+ "schemaVersion": {
11
+ "const": "iterate.run-plan/0.1"
12
+ },
13
+ "dimensions": {
14
+ "type": "array",
15
+ "minItems": 1,
16
+ "maxItems": 64,
17
+ "uniqueItems": true,
18
+ "items": {
19
+ "$ref": "#/$defs/plannedDimension"
20
+ }
21
+ },
22
+ "source": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["kind"],
26
+ "properties": {
27
+ "kind": {
28
+ "enum": ["config", "recipe", "session", "manual"]
29
+ },
30
+ "name": {
31
+ "type": "string",
32
+ "minLength": 1,
33
+ "maxLength": 512
34
+ }
35
+ }
36
+ }
37
+ },
38
+ "$defs": {
39
+ "plannedDimension": {
40
+ "type": "object",
41
+ "additionalProperties": false,
42
+ "required": ["id"],
43
+ "properties": {
44
+ "id": {
45
+ "type": "string",
46
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
47
+ "maxLength": 64
48
+ },
49
+ "label": {
50
+ "type": "string",
51
+ "maxLength": 256
52
+ },
53
+ "focus": {
54
+ "type": "string",
55
+ "maxLength": 4096
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }