opentakeoff-mcp 0.9.71 → 0.9.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,18 @@ native canvas, so every JSON tool and the text/metadata resources work
38
38
  everywhere; the sheet-image resource and the `view_sheet` tool say exactly
39
39
  what's missing where rendering isn't available.
40
40
 
41
+ ## One-Click is temporarily gated
42
+
43
+ `one_click` and `detect_rooms` are **not registered** on a default build while the flood
44
+ engine is re-validated against a wider plan corpus: `tools/list` never names them, the
45
+ initialize `instructions` say so and point at `measure_polygon`, and no other tool's
46
+ description sends an agent to a verb that is not there. A default build registers
47
+ **45 tools**. Everything else — sweeps, counts, `derive_base`, `derive_transitions`, the
48
+ exports — is unchanged. Set `OPENTAKEOFF_ONE_CLICK=1` in the server's environment to
49
+ register both verbs (47 tools); the parity, conformance and e2e tests run that way, and
50
+ `test/gate.test.ts` pins both surfaces. The rows and examples below that use `one_click`
51
+ describe the lifted build. Design note: [`docs/design/ONE_CLICK_GATE.md`](../docs/design/ONE_CLICK_GATE.md).
52
+
41
53
 
42
54
  The takeoff engine—One-Click Area, the scale model, conditions, totals—on
43
55
  **stdio for your MCP client**. An agent can open a plan, read the title block,
@@ -136,8 +148,8 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
136
148
  | `load_plan` | Open a plan PDF from disk. Default replaces the whole session; **`merge: true` ADDS the document to the working set** (#152)—plans + schedule + addenda as one takeoff, sheet graph spanning the whole set, marked set covering every worked sheet. Returns per-sheet dims, title-block `sheet_number`, and the detected drawn scale where present. |
137
149
  | `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
138
150
  | `set_scale` | Set a sheet's scale—exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. **Lands unconfirmed** (`confirmed: false`) until a human confirms in the canvas—see Scale rules. |
139
- | `one_click` | One-Click Area at (x, y): the sealed flood engine bounded by the plan linework, traced, vertices snapped—the SAME feet-true arguments the canvas passes at a click (gap sealing up to a door width, door-swing wedge inclusion, the half-foot minimum-passage rule), so an MCP trace and a canvas click at one seed measure the same square footage (pinned against the bench corpus goldens in `test/parity.test.ts`). Every trace carries the engine's account of itself: `confidence` 0–1 with `confidence_factors` naming each deduction (`gap_sealed_px`, `door_wedges`, `min_pass_delta`, …)—a review prioritizer, never a verification; a low score is a `view_sheet {overlay: true}` audit prompt, not a fact. On a SCANNED sheet (no usable linework) the flood falls back automatically to the rendered pixels—same engine as the canvas—with `raster_traced` disclosed on the reply and on the shape's origin (#154). Pass `condition` to commit (the full account stamps `origin` centrally at the commit); `role: "deduct"` subtracts. |
140
- | `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each—one call instead of `read_sheet_text` + reasoning + N `one_click` calls, through the SAME sealed engine per room (confidence + the engine account ride each room and its committed origin). Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible / unresolved), never dropped silently. To commit: `assign_from_schedule: true` routes each room through its OWN room-finish schedule row and commits under the FLOOR finish that row states (rooms the schedule can't answer for return in `unresolved[]` with reasons and re-seedable coordinates); or pass `condition` to commit every room under one stated tag. |
151
+ | `one_click` | **Temporarily gated — not registered unless `OPENTAKEOFF_ONE_CLICK=1`.** One-Click Area at (x, y): the sealed flood engine bounded by the plan linework, traced, vertices snapped—the SAME feet-true arguments the canvas passes at a click (gap sealing up to a door width, door-swing wedge inclusion, the half-foot minimum-passage rule), so an MCP trace and a canvas click at one seed measure the same square footage (pinned against the bench corpus goldens in `test/parity.test.ts`). Every trace carries the engine's account of itself: `confidence` 0–1 with `confidence_factors` naming each deduction (`gap_sealed_px`, `door_wedges`, `min_pass_delta`, …)—a review prioritizer, never a verification; a low score is a `view_sheet {overlay: true}` audit prompt, not a fact. On a SCANNED sheet (no usable linework) the flood falls back automatically to the rendered pixels—same engine as the canvas—with `raster_traced` disclosed on the reply and on the shape's origin (#154). Pass `condition` to commit (the full account stamps `origin` centrally at the commit); `role: "deduct"` subtracts. |
152
+ | `detect_rooms` | **Temporarily gated — not registered unless `OPENTAKEOFF_ONE_CLICK=1`.** Batch One-Click: reads every room-number label off the sheet's text layer and floods each—one call instead of `read_sheet_text` + reasoning + N `one_click` calls, through the SAME sealed engine per room (confidence + the engine account ride each room and its committed origin). Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible / unresolved), never dropped silently. To commit: `assign_from_schedule: true` routes each room through its OWN room-finish schedule row and commits under the FLOOR finish that row states (rooms the schedule can't answer for return in `unresolved[]` with reasons and re-seedable coordinates); or pass `condition` to commit every room under one stated tag. |
141
153
  | `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
142
154
  | `cut_out` | **A real hole in a committed floor shape** (#206), reconciled the way the canvas cuts one (#137)—the same `lib/cutout.js` boolean subtract, one module, so a headless session and the app can never disagree about what a hole holds. The parent keeps its outer ring + `verts_norm_holes`, its `computed` nets for real (N cuts compose, overlap never double-deducts, a hole ADDS perimeter), and the deduct carries `cuts_shape_id` so report/legend read the reconciled number. Refuses a ring not FULLY inside the parent (the canvas's edge-clip is a canvas affordance; over the wire it's refusal-over-guessing) and a cut that would erase or split the parent. One undo step restores parent and hole together; `delete_shape` on the deduct reverts the cut too (multi-cut parents rebuild from the chain's pristine snapshot minus survivors—the canvas's own delete semantics, ported as the spec). |
143
155
  | `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
@@ -398,3 +410,11 @@ npm run build
398
410
  node scripts/build-smithery-mcpb.mjs
399
411
  smithery mcp publish dist-smithery/opentakeoff-mcp.mcpb -n Kentucky-ai/opentakeoff
400
412
  ```
413
+
414
+ ## Calibration and review correctness (0.9.72)
415
+
416
+ `set_scale` recomputes existing dimensional quantities from geometry, including holes and cutout restore snapshots. Changing an existing calibration records one `undo_last` step that restores the scale, its confirmation/source, and the prior quantities together. Initial calibration of an unmeasured sheet adds no undo step. Counts retain their stored values. A sheet containing human-reviewed dimensional measurements refuses recalibration over MCP, consistent with the existing reviewed-shape edit rules; recalibrate it in the canvas and import the updated takeoff into a fresh session.
417
+
418
+ `import_takeoff` refuses new dimensional shapes when their source calibration differs from the session's calibration, or is missing while the session has one. The error names the sheet and scales; no session state changes. Align calibrations and re-export, or load a fresh session to adopt the export's calibration. Counts and duplicate IDs are exempt. An existing calibration is preserved even in an untraced session.
419
+
420
+ New agent measurements, including `measure_polygon` and `measure_line`, explicitly carry `origin.reviewed: false`. Legacy agent records without the flag are normalized on import and browser reload. Explicit prior human approval is preserved. No new review gate is introduced.
@@ -5812,6 +5812,41 @@ function labelPlacements(placements, spans, segs, lum) {
5812
5812
  return out;
5813
5813
  }
5814
5814
 
5815
+ // ../web/src/lib/shapeMetrics.js
5816
+ function computeShapeMetrics(s, dims, upp, cond) {
5817
+ const pts = (s.verts_norm || []).map(([nx, ny]) => [nx * dims.w, ny * dims.h]);
5818
+ const u = upp || 0;
5819
+ if (s.measure_role === "count") return { count: 1 };
5820
+ if (s.measure_role === "surface_area") {
5821
+ const h = s.height_override === true ? Number(s.height_ft) || 0 : Number(s.height_ft) || Number(cond?.height_ft) || 0;
5822
+ const LF = openLen(pts) * u;
5823
+ return { area_sf: +(LF * h).toFixed(2), perimeter_lf: +LF.toFixed(2) };
5824
+ }
5825
+ if (s.measure_role === "linear") {
5826
+ const LF = openLen(s.curved ? flattenCurve(pts) : pts) * u;
5827
+ const tIn = Number(cond?.thickness_in) || 0;
5828
+ return { perimeter_lf: +LF.toFixed(2), area_sf: tIn > 0 ? +(LF * tIn / 12).toFixed(2) : 0 };
5829
+ }
5830
+ const holesPx = (s.verts_norm_holes || []).map((ring) => ring.map(([nx, ny]) => [nx * dims.w, ny * dims.h]));
5831
+ const met = holesPx.length ? polyWithHolesMetrics(pts, holesPx) : closedMetrics(pts);
5832
+ return { area_sf: +(met.area * u * u).toFixed(2), perimeter_lf: +(met.perim * u).toFixed(2) };
5833
+ }
5834
+ function recalibrateShapes(shapes, dims, upp, conditions) {
5835
+ const byId = new Map(shapes.map((s) => [s.id, s]));
5836
+ const conds = new Map(conditions.map((c) => [c.id, c]));
5837
+ return shapes.map((s) => {
5838
+ if (s.measure_role === "count") return s;
5839
+ const next = { ...s, computed: computeShapeMetrics(s, dims, upp, conds.get(s.condition_id)) };
5840
+ const prev = s.origin?.parent_prev;
5841
+ const parent = prev && byId.get(s.cuts_shape_id);
5842
+ if (prev && parent) next.origin = { ...s.origin, parent_prev: {
5843
+ ...prev,
5844
+ computed: computeShapeMetrics({ ...parent, ...prev, verts_norm_holes: prev.verts_norm_holes }, dims, upp, conds.get(parent.condition_id))
5845
+ } };
5846
+ return next;
5847
+ });
5848
+ }
5849
+
5815
5850
  // ../web/src/lib/transitions.ts
5816
5851
  var COINCIDENT_PX = 1e-9;
5817
5852
  function segments(ring) {
@@ -7882,6 +7917,26 @@ var Session = class _Session {
7882
7917
  } else {
7883
7918
  throw new UserError("Provide exactly one of: label, upp, calibrate, use_detected.");
7884
7919
  }
7920
+ if (!Number.isFinite(upp) || upp <= 0) throw new UserError("Scale must be a finite positive number.");
7921
+ if (s.upp !== upp) {
7922
+ const before = this.shapes.filter((sh) => sh.sheet_id === s.key);
7923
+ if (before.some((sh) => sh.measure_role !== "count" && sh.origin?.reviewed === true)) {
7924
+ throw new UserError("This sheet contains human-reviewed measurements. Recalibrate it in the canvas, then import the updated takeoff into a fresh session.");
7925
+ }
7926
+ const dims = { w: s.widthPx, h: s.heightPx };
7927
+ const repriced = recalibrateShapes(before, dims, upp, this.conditions);
7928
+ if (s.upp !== null || before.length) this.record({
7929
+ op: "scale",
7930
+ tool: "set_scale",
7931
+ sheet_id: s.key,
7932
+ upp: s.upp,
7933
+ source: s.scaleSource,
7934
+ confirmed: s.scaleConfirmed,
7935
+ shapes: structuredClone(before)
7936
+ });
7937
+ const byId = new Map(repriced.map((sh) => [sh.id, sh]));
7938
+ this.shapes = this.shapes.map((sh) => byId.get(sh.id) ?? sh);
7939
+ }
7885
7940
  if (s.upp !== upp) {
7886
7941
  s.mask = void 0;
7887
7942
  s.rmask = void 0;
@@ -7973,6 +8028,7 @@ var Session = class _Session {
7973
8028
  }
7974
8029
  commit(s, tag, role, vertsPx, computed, origin, flood) {
7975
8030
  if (origin && flood) origin = { ...origin, ..._Session.floodStamp(flood, computed.area_sf) };
8031
+ if (origin?.actor === "agent") origin = { ...origin, reviewed: false };
7976
8032
  if (origin?.actor === "agent" && !origin.assignment) origin = { ...origin, assignment: { source: "asserted" } };
7977
8033
  const c = this.conditionFor(tag);
7978
8034
  const shape = {
@@ -9960,7 +10016,17 @@ var Session = class _Session {
9960
10016
  for (let k = 0; k < n; k++) {
9961
10017
  const e = this.journal.pop();
9962
10018
  if (!e) break;
9963
- if (e.op === "commit") {
10019
+ if (e.op === "scale") {
10020
+ const s = this.sheet(e.sheet_id);
10021
+ s.upp = e.upp;
10022
+ s.scaleSource = e.source;
10023
+ s.scaleConfirmed = e.confirmed;
10024
+ s.mask = void 0;
10025
+ s.rmask = void 0;
10026
+ const before = new Map(e.shapes.map((sh) => [sh.id, sh]));
10027
+ this.shapes = this.shapes.map((sh) => before.get(sh.id) ?? sh);
10028
+ undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: e.shapes.length });
10029
+ } else if (e.op === "commit") {
9964
10030
  const dead = new Set(e.ids);
9965
10031
  this.shapes = this.shapes.filter((x) => !dead.has(x.id));
9966
10032
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: e.ids.length });
@@ -10743,6 +10809,15 @@ var Session = class _Session {
10743
10809
  // src/tools.ts
10744
10810
  import { z as z2 } from "zod";
10745
10811
 
10812
+ // src/gate.ts
10813
+ var GATED_TOOLS = Object.freeze(["one_click", "detect_rooms"]);
10814
+ var ONE_CLICK_ENV = "OPENTAKEOFF_ONE_CLICK";
10815
+ function oneClickEnabled(explicit) {
10816
+ return explicit ?? process.env[ONE_CLICK_ENV] === "1";
10817
+ }
10818
+ var GATE_NOTE = "ONE-CLICK IS TEMPORARILY GATED: one_click and detect_rooms are NOT registered on this server while the flood engine is re-validated against a wider plan corpus \u2014 do not call them, they do not exist here. Measure a room with measure_polygon on the wall faces you read from get_sheet_vectors and confirm with view_sheet; sweeps, counts, derive_base and derive_transitions are unchanged and still read committed floor shapes.";
10819
+ var GATE_HINT = "measure_polygon (One-Click is temporarily gated on this server)";
10820
+
10746
10821
  // src/trace.ts
10747
10822
  var TRACE_ENV = "OPENTAKEOFF_MCP_TRACE";
10748
10823
  function traceToolCall(tool, args, startedAt, reply) {
@@ -11192,7 +11267,7 @@ var undoLastOutput = {
11192
11267
  // EVERY JournalPayload op (session.ts) belongs here — the wire validates
11193
11268
  // undo_last's reply against this enum, so a journal op missing from it
11194
11269
  // fails the undo call itself. Add the op here in the same change.
11195
- op: z.enum(["commit", "edit", "delete", "materials", "condition", "approval", "duplicate_condition", "split_condition", "cutout", "cutout_restore", "runcut", "rfi_create", "rfi_resolve", "rfi_delete"]),
11270
+ op: z.enum(["commit", "scale", "edit", "delete", "materials", "condition", "approval", "duplicate_condition", "split_condition", "cutout", "cutout_restore", "runcut", "rfi_create", "rfi_resolve", "rfi_delete"]),
11196
11271
  tool: z.string().describe("The tool call this step came from"),
11197
11272
  shapes: z.number().int().describe("Shapes affected by reversing this step \u2014 0 for a materials step (it restores a condition's supporting-materials rows, not shapes), for a condition step (it restores the waste/multiplier pair), and for an approval step (it re-seats or removes a verdict mark)")
11198
11273
  })).describe("Newest first"),
@@ -13058,6 +13133,11 @@ var DEFAULT_STAMP_SETS = [
13058
13133
  // ../web/src/lib/store.js
13059
13134
  var ANN_SCHEMA2 = "opentakeoff.takeoff_canvas.v1";
13060
13135
 
13136
+ // ../web/src/lib/reviewState.js
13137
+ function normalizeAgentReview(shape) {
13138
+ return shape?.origin?.actor === "agent" && shape.origin.reviewed == null ? { ...shape, origin: { ...shape.origin, reviewed: false } } : shape;
13139
+ }
13140
+
13061
13141
  // ../web/src/lib/importTakeoff.js
13062
13142
  function parseTakeoffImport(text) {
13063
13143
  let doc;
@@ -13080,17 +13160,28 @@ var freeId = (id, taken) => {
13080
13160
  };
13081
13161
  function mergeTakeoffImport(current, imported, knownFiles = null) {
13082
13162
  const cur = current && typeof current === "object" ? current : {};
13083
- const impShapes = arr(imported.shapes).filter((s) => s && typeof s === "object" && typeof s.sheet_id === "string" && typeof s.id === "string");
13163
+ const impShapes = arr(imported.shapes).filter((s) => s && typeof s === "object" && typeof s.sheet_id === "string" && typeof s.id === "string").map(normalizeAgentReview);
13084
13164
  const impConds = arr(imported.conditions).filter((c) => c && typeof c === "object" && typeof c.id === "string");
13165
+ const localScales = new Map(arr(cur.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
13166
+ const incomingScales = new Map(arr(imported.sheets).filter((s) => typeof s?.sheet_id === "string").map((s) => [s.sheet_id, s.units_per_px]));
13167
+ const existingIds = new Set(arr(cur.shapes).map((s) => s.id));
13168
+ for (const s of impShapes) {
13169
+ if (existingIds.has(s.id) || s.measure_role === "count") continue;
13170
+ const local = localScales.get(s.sheet_id), incoming = incomingScales.get(s.sheet_id);
13171
+ if (local > 0 && (!(incoming > 0) || !Number.isFinite(incoming) || Math.abs(local - incoming) > Math.max(local, incoming) * 1e-9)) {
13172
+ throw new Error(`Couldn't import takeoff: scale conflict on ${s.sheet_id} (current ${local} ft/px; imported ${incoming ?? "missing"}). Align the sheet calibration and re-export before importing measurements. Nothing was imported.`);
13173
+ }
13174
+ }
13085
13175
  const unknownFiles = (added) => {
13086
13176
  if (!Array.isArray(knownFiles)) return [];
13087
13177
  const known = new Set(knownFiles);
13088
13178
  return [...new Set(added.map((s) => String(s.sheet_id).split("#")[0]).filter((f) => !known.has(f)))];
13089
13179
  };
13090
13180
  const pendingCount = (shapes) => shapes.filter((s) => s.origin?.reviewed === false).length;
13091
- if (!arr(cur.shapes).length && !arr(cur.markups).length && !arr(cur.approvals).length) {
13181
+ if (!arr(cur.shapes).length && !arr(cur.markups).length && !arr(cur.approvals).length && !arr(cur.sheets).some((s) => s?.units_per_px > 0)) {
13092
13182
  const payload2 = {
13093
13183
  ...imported,
13184
+ shapes: impShapes,
13094
13185
  ...arr(imported.sheet_tabs).length ? {} : { sheet_tabs: arr(cur.sheet_tabs) },
13095
13186
  ...arr(imported.sheet_group).length ? {} : { sheet_group: arr(cur.sheet_group) },
13096
13187
  ...arr(imported.last_group).length ? {} : { last_group: arr(cur.last_group) }
@@ -13187,7 +13278,13 @@ async function importTakeoff(session, filePath) {
13187
13278
  throw new UserError(e instanceof Error ? e.message : String(e));
13188
13279
  }
13189
13280
  const prevShapeIds = new Set(session.shapes.map((s) => s.id));
13190
- const { payload, note } = mergeTakeoffImport2(session.exportPayload(), imported, session.files);
13281
+ const { payload, note } = (() => {
13282
+ try {
13283
+ return mergeTakeoffImport2(session.exportPayload(), imported, session.files);
13284
+ } catch (e) {
13285
+ throw new UserError(e instanceof Error ? e.message : String(e));
13286
+ }
13287
+ })();
13191
13288
  session.conditions = payload.conditions ?? [];
13192
13289
  session.shapes = payload.shapes ?? [];
13193
13290
  session.markups = payload.markups ?? [];
@@ -13247,7 +13344,8 @@ var run = (tool, fn) => async (args) => {
13247
13344
  traceToolCall(tool, args, startedAt, reply);
13248
13345
  return reply;
13249
13346
  };
13250
- function registerTools(realServer, session) {
13347
+ function registerTools(realServer, session, opts = {}) {
13348
+ const oneClick = oneClickEnabled(opts.oneClick);
13251
13349
  const registered = /* @__PURE__ */ new Map();
13252
13350
  const server = {
13253
13351
  registerTool(name, meta, handler) {
@@ -13270,12 +13368,12 @@ function registerTools(realServer, session) {
13270
13368
  return loaded;
13271
13369
  }));
13272
13370
  server.registerTool("sheet_info", {
13273
- description: `Sheet detail: dims (px and pt), vector segment count, whether the sheet has vector linework (one_click floods it when present; a scanned sheet falls back to rendered pixels, disclosed as raster_traced), scale status, the detected scale suggestion, and this sheet's committed shape count. ${COORDS}`,
13371
+ description: `Sheet detail: dims (px and pt), vector segment count, whether the sheet has vector linework (${oneClick ? "one_click floods it when present; a scanned sheet falls back to rendered pixels, disclosed as raster_traced" : "measure_polygon runs on it; get_sheet_vectors returns the strokes"}), scale status, the detected scale suggestion, and this sheet's committed shape count. ${COORDS}`,
13274
13372
  inputSchema: { sheet: z2.string().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101")') },
13275
13373
  outputSchema: sheetInfoOutput
13276
13374
  }, run("sheet_info", ({ sheet }) => session.sheetInfo(sheet)));
13277
13375
  server.registerTool("set_scale", {
13278
- description: `Set a sheet's scale \u2014 exactly ONE of: label (a standard scale, e.g. '1/4" = 1'-0"'), upp (real feet per image px), calibrate (two points along a known dimension plus its real feet), or use_detected (adopt the drawn scale note read off the sheet). The detected scale is never applied automatically \u2014 setting it is always this explicit call. ${COORDS}`,
13376
+ description: `Set a sheet's scale \u2014 exactly ONE of: label (a standard scale, e.g. '1/4" = 1'-0"'), upp (real feet per image px), calibrate (two points along a known dimension plus its real feet), or use_detected (adopt the drawn scale note read off the sheet). The detected scale is never applied automatically \u2014 setting it is always this explicit call. Changing an existing scale recomputes measurements and cutout restore quantities from geometry and records one undo_last step. Counts are unchanged. Human-reviewed dimensional work must be recalibrated in the canvas; scales must be finite and positive. ${COORDS}`,
13279
13377
  inputSchema: {
13280
13378
  sheet: z2.string(),
13281
13379
  label: z2.string().optional().describe("A standard scale label, exactly as listed in the error on a miss"),
@@ -13289,39 +13387,41 @@ function registerTools(realServer, session) {
13289
13387
  if (given !== 1) throw new UserError("Provide exactly one of: label, upp, calibrate, use_detected.");
13290
13388
  return session.setScale(a.sheet, a);
13291
13389
  }));
13292
- server.registerTool("one_click", {
13293
- description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 the sealed flood engine (RFC #60), contour trace, vertices snapped to true PDF endpoints. The engine's arguments are FEET-TRUE through the sheet's scale, exactly the canvas's: gap sealing bridges up to a door-width opening (disclosed as gap_sealed_px \u2014 that much boundary is synthetic), door-swing wedges annex the swing a doorway sweeps (door_wedges), and the minimum-passage rule keeps sub-half-foot slits from conjoining two rooms (min_pass_px/min_pass_delta). Every trace carries the engine's own account of itself: confidence (0..1, with confidence_factors naming what deducted) \u2014 a review PRIORITIZER, never a verification. 1.0 means every signal ran clean, not that the trace is right; a LOW confidence is a view_sheet {overlay: true} audit prompt, not a fact to bid from \u2014 put eyes on the flagged edge before the total means anything. SCANNED sheets work too (#154): where vectors can't bound the room (an image-only scan, or a scan wrapper whose only linework is the title block), the flood falls back automatically to the sheet's rendered pixels \u2014 same engine the canvas uses \u2014 and the reply plus the committed shape's origin carry raster_traced: true so a pixel-bounded ring is never mistaken for a vector-snapped one. Vector always wins where it works; a raster ring's corners are unsnapped, so audit it with view_sheet {overlay: true} before trusting the total. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff \u2014 the full engine account rides the committed shape's origin, so the export tells the truth about how each shape was made. Without a scale it returns px-only quantities with a warning and commits nothing (the engine also degrades to its scale-blind fallbacks \u2014 a weaker measurement, one more reason set_scale comes first). role "deduct" makes the committed shape subtract. After committing, LOOK at what landed \u2014 view_sheet {overlay: true} \u2014 and fix an overshot ring with edit_shape before trusting any total. ${COORDS}`,
13294
- inputSchema: {
13295
- sheet: z2.string(),
13296
- x: z2.number(),
13297
- y: z2.number(),
13298
- condition: z2.string().optional().describe("Finish tag to commit under (minted on first use)"),
13299
- role: roleSchema,
13300
- return_verts: z2.boolean().default(false).describe("Include the traced polygon's vertices (image px)"),
13301
- sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing"),
13302
- layers: layersFilterSchema
13303
- },
13304
- outputSchema: oneClickOutput
13305
- }, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts, sensitivity: a.sensitivity, layers: a.layers })));
13306
- server.registerTool("detect_rooms", {
13307
- description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. An OCR'd scan (text layer, no vector linework) floods the rendered pixels instead (#154), disclosed per room and on origin as raster_traced. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. Every room floods through the SAME sealed engine a single one_click runs (RFC #60 \u2014 feet-true gap sealing, door-swing wedges, the minimum-passage rule), so a batch detection and a click at the same seed measure the same square footage; each room carries the engine's account of its own trace (confidence + confidence_factors, gap_sealed_px, door_wedges, min_pass_px/min_pass_delta), and the same account rides origin on everything committed. Confidence is a review prioritizer, never a verification \u2014 a low-confidence room is a view_sheet {overlay: true} audit prompt, not a fact to bid from. With the sheet's scale set, returns area_sf/perimeter_lf per room. Every committed room carries the room number it was traced from as the shape's \`label\`, so a sweep arrives already sliced by room \u2014 that field is what the Report's per-room grouping and the workbook's floor \xD7 room tab read, and it is the one thing about a batch that cannot be recovered downstream if it is dropped. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
13308
- inputSchema: {
13309
- sheet: z2.string(),
13310
- condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
13311
- assign_from_schedule: z2.boolean().default(false).describe(`Commit each room under the FLOOR finish its OWN room-finish schedule row states (resolve_tag's chain, per room): the citation rides origin.assignment, and rooms the schedule cannot answer for \u2014 no row, no FLOOR cell, a compound cell like "CPT-1/VCT-1" \u2014 are returned in unresolved[] with reasons and seeds instead of committed under a guess. Needs the sheet's scale and a room-finish schedule in the working set (merge the schedule sheet in with load_plan first). Mutually exclusive with condition`),
13312
- role: roleSchema,
13313
- return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
13314
- min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed non-bubble regions smaller than this are withheld as cavities, not rooms. Default 5 SF \u2014 below any real finished space (a broom closet is ~10 SF). Lower it to inspect what was skipped."),
13315
- sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing"),
13316
- layers: layersFilterSchema
13317
- },
13318
- outputSchema: detectRoomsOutput
13319
- }, run("detect_rooms", (a) => {
13320
- if (a.assign_from_schedule && a.condition !== void 0) {
13321
- throw new UserError("Provide at most one of: condition (every room under one stated tag) or assign_from_schedule (each room's own schedule row decides).");
13322
- }
13323
- return session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf, sensitivity: a.sensitivity, layers: a.layers, assignFromSchedule: a.assign_from_schedule });
13324
- }));
13390
+ if (oneClick) {
13391
+ server.registerTool("one_click", {
13392
+ description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 the sealed flood engine (RFC #60), contour trace, vertices snapped to true PDF endpoints. The engine's arguments are FEET-TRUE through the sheet's scale, exactly the canvas's: gap sealing bridges up to a door-width opening (disclosed as gap_sealed_px \u2014 that much boundary is synthetic), door-swing wedges annex the swing a doorway sweeps (door_wedges), and the minimum-passage rule keeps sub-half-foot slits from conjoining two rooms (min_pass_px/min_pass_delta). Every trace carries the engine's own account of itself: confidence (0..1, with confidence_factors naming what deducted) \u2014 a review PRIORITIZER, never a verification. 1.0 means every signal ran clean, not that the trace is right; a LOW confidence is a view_sheet {overlay: true} audit prompt, not a fact to bid from \u2014 put eyes on the flagged edge before the total means anything. SCANNED sheets work too (#154): where vectors can't bound the room (an image-only scan, or a scan wrapper whose only linework is the title block), the flood falls back automatically to the sheet's rendered pixels \u2014 same engine the canvas uses \u2014 and the reply plus the committed shape's origin carry raster_traced: true so a pixel-bounded ring is never mistaken for a vector-snapped one. Vector always wins where it works; a raster ring's corners are unsnapped, so audit it with view_sheet {overlay: true} before trusting the total. With the sheet's scale set, returns area_sf / perimeter_lf; pass condition (a finish tag, e.g. "CPT-1") to commit the traced shape to the takeoff \u2014 the full engine account rides the committed shape's origin, so the export tells the truth about how each shape was made. Without a scale it returns px-only quantities with a warning and commits nothing (the engine also degrades to its scale-blind fallbacks \u2014 a weaker measurement, one more reason set_scale comes first). role "deduct" makes the committed shape subtract. After committing, LOOK at what landed \u2014 view_sheet {overlay: true} \u2014 and fix an overshot ring with edit_shape before trusting any total. ${COORDS}`,
13393
+ inputSchema: {
13394
+ sheet: z2.string(),
13395
+ x: z2.number(),
13396
+ y: z2.number(),
13397
+ condition: z2.string().optional().describe("Finish tag to commit under (minted on first use)"),
13398
+ role: roleSchema,
13399
+ return_verts: z2.boolean().default(false).describe("Include the traced polygon's vertices (image px)"),
13400
+ sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing"),
13401
+ layers: layersFilterSchema
13402
+ },
13403
+ outputSchema: oneClickOutput
13404
+ }, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts, sensitivity: a.sensitivity, layers: a.layers })));
13405
+ server.registerTool("detect_rooms", {
13406
+ description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. An OCR'd scan (text layer, no vector linework) floods the rendered pixels instead (#154), disclosed per room and on origin as raster_traced. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. Every room floods through the SAME sealed engine a single one_click runs (RFC #60 \u2014 feet-true gap sealing, door-swing wedges, the minimum-passage rule), so a batch detection and a click at the same seed measure the same square footage; each room carries the engine's account of its own trace (confidence + confidence_factors, gap_sealed_px, door_wedges, min_pass_px/min_pass_delta), and the same account rides origin on everything committed. Confidence is a review prioritizer, never a verification \u2014 a low-confidence room is a view_sheet {overlay: true} audit prompt, not a fact to bid from. With the sheet's scale set, returns area_sf/perimeter_lf per room. Every committed room carries the room number it was traced from as the shape's \`label\`, so a sweep arrives already sliced by room \u2014 that field is what the Report's per-room grouping and the workbook's floor \xD7 room tab read, and it is the one thing about a batch that cannot be recovered downstream if it is dropped. TO COMMIT, choose the honest source of the finish tag: assign_from_schedule: true routes every room through its OWN room-finish schedule row and commits each under the FLOOR finish that row states \u2014 when a schedule exists in the set, THIS is the default move, because one agent-chosen tag across N rooms flattens real finish variety into a wrong bid; condition commits every room under that one stated tag (only right when the rooms genuinely share it; role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. A batch commit is NOT finished until you have LOOKED at it: view_sheet {overlay: true}, audit every ring against the walls, fix misses with edit_shape / delete_shape \u2014 before the totals mean anything. ${COORDS}`,
13407
+ inputSchema: {
13408
+ sheet: z2.string(),
13409
+ condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
13410
+ assign_from_schedule: z2.boolean().default(false).describe(`Commit each room under the FLOOR finish its OWN room-finish schedule row states (resolve_tag's chain, per room): the citation rides origin.assignment, and rooms the schedule cannot answer for \u2014 no row, no FLOOR cell, a compound cell like "CPT-1/VCT-1" \u2014 are returned in unresolved[] with reasons and seeds instead of committed under a guess. Needs the sheet's scale and a room-finish schedule in the working set (merge the schedule sheet in with load_plan first). Mutually exclusive with condition`),
13411
+ role: roleSchema,
13412
+ return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
13413
+ min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed non-bubble regions smaller than this are withheld as cavities, not rooms. Default 5 SF \u2014 below any real finished space (a broom closet is ~10 SF). Lower it to inspect what was skipped."),
13414
+ sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing"),
13415
+ layers: layersFilterSchema
13416
+ },
13417
+ outputSchema: detectRoomsOutput
13418
+ }, run("detect_rooms", (a) => {
13419
+ if (a.assign_from_schedule && a.condition !== void 0) {
13420
+ throw new UserError("Provide at most one of: condition (every room under one stated tag) or assign_from_schedule (each room's own schedule row decides).");
13421
+ }
13422
+ return session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf, sensitivity: a.sensitivity, layers: a.layers, assignFromSchedule: a.assign_from_schedule });
13423
+ }));
13424
+ }
13325
13425
  server.registerTool("measure_polygon", {
13326
13426
  description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. ${COORDS}`,
13327
13427
  inputSchema: {
@@ -13350,7 +13450,7 @@ function registerTools(realServer, session) {
13350
13450
  outputSchema: measureLineOutput
13351
13451
  }, run("measure_line", (a) => session.measureLine(a.sheet, a.pts, { condition: a.condition })));
13352
13452
  server.registerTool("measure_surface", {
13353
- description: `Surface Area \u2014 wall SF (#146): trace an OPEN run along the wall in plan view (min 2 points, image px) and the quantity is traced LF \xD7 height. This is how wall tile, wainscot, and wall systems are taken off \u2014 the quantity family one_click and measure_polygon cannot produce. Height lives on the CONDITION (the canvas's H knob): pass height_ft to set it on this call (journals as its own undo step, like typing H before tracing), or set it once with edit_condition; with neither, this refuses and mints nothing. The shape snapshots the height it was quantified at. Requires the sheet's scale. ${COORDS}`,
13453
+ description: `Surface Area \u2014 wall SF (#146): trace an OPEN run along the wall in plan view (min 2 points, image px) and the quantity is traced LF \xD7 height. This is how wall tile, wainscot, and wall systems are taken off \u2014 the quantity family ${oneClick ? "one_click and " : ""}measure_polygon cannot produce. Height lives on the CONDITION (the canvas's H knob): pass height_ft to set it on this call (journals as its own undo step, like typing H before tracing), or set it once with edit_condition; with neither, this refuses and mints nothing. The shape snapshots the height it was quantified at. Requires the sheet's scale. ${COORDS}`,
13354
13454
  inputSchema: {
13355
13455
  sheet: z2.string(),
13356
13456
  pts: z2.array(pointSchema).min(2).describe("The wall run, an open polyline (image px)"),
@@ -13360,7 +13460,7 @@ function registerTools(realServer, session) {
13360
13460
  outputSchema: measureSurfaceOutput
13361
13461
  }, run("measure_surface", (a) => session.measureSurface(a.sheet, a.pts, { condition: a.condition, height_ft: a.height_ft })));
13362
13462
  server.registerTool("place_count", {
13363
- description: `Count markers \u2014 EA (#146): one point, one each. Thresholds, stair nosings, floor boxes, entrance mats \u2014 the scale-free quantity family. Commits one count shape per point (computed {count: 1}, exactly the canvas's Count tool), NO scale required, and the whole call is ONE undo step like a detect_rooms sweep. takeoff_summary reports them as ea; the marked set draws each marker. ${COORDS}`,
13463
+ description: `Count markers \u2014 EA (#146): one point, one each. Thresholds, stair nosings, floor boxes, entrance mats \u2014 the scale-free quantity family. Commits one count shape per point (computed {count: 1}, exactly the canvas's Count tool), NO scale required, and the whole call is ONE undo step${oneClick ? " like a detect_rooms sweep" : ""}. takeoff_summary reports them as ea; the marked set draws each marker. ${COORDS}`,
13364
13464
  inputSchema: {
13365
13465
  sheet: z2.string(),
13366
13466
  points: z2.array(pointSchema).min(1).describe("Marker positions (image px), one committed count shape each"),
@@ -13521,7 +13621,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
13521
13621
  return doc;
13522
13622
  }));
13523
13623
  server.registerTool("import_takeoff", {
13524
- description: `The way BACK IN (#151): load an "opentakeoff.takeoff_canvas.v1" file \u2014 a prior export_takeoff, or the app's own save \u2014 into this session, through the SAME tested merge rules as the app's Sheet-menu import: finish-tag identity joins imported conditions onto this session's own (their knobs win), new ids append, duplicate ids skip (re-import is idempotent), and THIS session's calibration wins per sheet. An empty session adopts the file wholesale. Resume yesterday's work, extend a takeoff a human already reviewed (their ink stays ink \u2014 reviewed shapes arrive untouchable by agent verbs), or audit someone else's export with list_shapes/takeoff_summary. Requires a loaded plan; shapes referencing OTHER files ride along and count in totals but can't be viewed against this document \u2014 the reply's unknown_files names them. Approval marks ride the file too \u2014 transport, not minting: an estimator seal arriving by import stays estimator ink, listable but untouchable here. undo_last removes the imported SHAPES as one step; adopted conditions, scales, annotations, and approval marks stay.`,
13624
+ description: `The way BACK IN (#151): load an "opentakeoff.takeoff_canvas.v1" file \u2014 a prior export_takeoff, or the app's own save \u2014 into this session, through the SAME tested merge rules as the app's Sheet-menu import: finish-tag identity joins imported conditions onto this session's own (their knobs win), new ids append, duplicate ids skip (re-import is idempotent), and THIS session's calibration wins per sheet. An uncalibrated empty session adopts the file wholesale. New dimensional shapes refuse atomically if their source scale differs from the session calibration or is missing; align scales and re-export, or use a fresh session. Counts and duplicate IDs are exempt. Legacy agent traces without review flags arrive reviewed:false. Resume yesterday's work, extend a takeoff a human already reviewed (their ink stays ink \u2014 reviewed shapes arrive untouchable by agent verbs), or audit someone else's export with list_shapes/takeoff_summary. Requires a loaded plan; shapes referencing OTHER files ride along and count in totals but can't be viewed against this document \u2014 the reply's unknown_files names them. Approval marks ride the file too \u2014 transport, not minting: an estimator seal arriving by import stays estimator ink, listable but untouchable here. undo_last removes the imported SHAPES as one step; adopted conditions, scales, annotations, and approval marks stay.`,
13525
13625
  inputSchema: {
13526
13626
  path: z2.string().describe("Path to a takeoff_canvas.v1 JSON file on disk")
13527
13627
  },
@@ -13567,7 +13667,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
13567
13667
  outputSchema: sheetContextOutput
13568
13668
  }, run("sheet_context", (a) => session.sheetContext(a.sheet, { region: a.region, min_len_px: a.min_len_px, max_segments: a.max_segments })));
13569
13669
  server.registerTool("get_sheet_vectors", {
13570
- description: `The STROKES \u2014 the sheet's vector layer exactly as the engine is fed it, so you can run your own geometry against what the app sees (#367). view_sheet lets you look, read_sheet_text lets you read, sheet_context classifies a region; this returns the raw extractor output that all of them and every shape verb (one_click's flood, the wall network's pen weights, symbol_sweep's matching) work from: flat points [x1, y1, x2, y2, \u2026] in image px, one meta byte per segment (low nibble flags: 1 curve chord, 2 clip-only, 4 fill-only, 8 polyline arc; pen width = meta >> 4), per-segment stroke luminance, the drawn figure each segment belongs to (subpath ordinal), the sheet's placed-image area, and its PDF layer table with a per-segment layer index (sheet_info.layers names and classifies the same ids). Nothing is classified, decimated, or merged here \u2014 segments arrive whole, in extraction order, undecimated, which is the point: a reader can build its own room finder, symbol matcher, or wall classifier on the same array and commit through the existing verbs with provenance intact. Paged, never clipped silently: a dense sheet runs to hundreds of thousands of segments, so the reply carries limit (default ${VECTORS_DEFAULT_LIMIT} segments, ceiling ${VECTORS_LIMIT_CEIL}) and the ledger offset + returned + dropped === total on every page; dropped is exactly what passing next_cursor as cursor recovers. region keeps every segment that intersects the rect (endpoints untouched \u2014 the same keep test sheet_context uses, so total here equals sheet_context's total_in_region) and echoes it post-clamp. Read-only and stateless \u2014 no shape, condition, or scale is touched. A scan has no strokes: the verb refuses and names view_sheet as the path. ${COORDS}`,
13670
+ description: `The STROKES \u2014 the sheet's vector layer exactly as the engine is fed it, so you can run your own geometry against what the app sees (#367). view_sheet lets you look, read_sheet_text lets you read, sheet_context classifies a region; this returns the raw extractor output that all of them and every shape verb (${oneClick ? "one_click's flood, " : ""}the wall network's pen weights, symbol_sweep's matching) work from: flat points [x1, y1, x2, y2, \u2026] in image px, one meta byte per segment (low nibble flags: 1 curve chord, 2 clip-only, 4 fill-only, 8 polyline arc; pen width = meta >> 4), per-segment stroke luminance, the drawn figure each segment belongs to (subpath ordinal), the sheet's placed-image area, and its PDF layer table with a per-segment layer index (sheet_info.layers names and classifies the same ids). Nothing is classified, decimated, or merged here \u2014 segments arrive whole, in extraction order, undecimated, which is the point: a reader can build its own room finder, symbol matcher, or wall classifier on the same array and commit through the existing verbs with provenance intact. Paged, never clipped silently: a dense sheet runs to hundreds of thousands of segments, so the reply carries limit (default ${VECTORS_DEFAULT_LIMIT} segments, ceiling ${VECTORS_LIMIT_CEIL}) and the ledger offset + returned + dropped === total on every page; dropped is exactly what passing next_cursor as cursor recovers. region keeps every segment that intersects the rect (endpoints untouched \u2014 the same keep test sheet_context uses, so total here equals sheet_context's total_in_region) and echoes it post-clamp. Read-only and stateless \u2014 no shape, condition, or scale is touched. A scan has no strokes: the verb refuses and names view_sheet as the path. ${COORDS}`,
13571
13671
  inputSchema: {
13572
13672
  sheet: z2.string().describe('Sheet key ("plan.pdf", "plan.pdf#2") or title-block number ("A-101")'),
13573
13673
  region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional().describe("Rect in image px (origin top-left, y down); omit for the full sheet. A segment is kept when it intersects the rect"),
@@ -13577,7 +13677,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
13577
13677
  outputSchema: getSheetVectorsOutput
13578
13678
  }, run("get_sheet_vectors", (a) => session.sheetVectors(a.sheet, { region: a.region, limit: a.limit, cursor: a.cursor })));
13579
13679
  server.registerTool("edit_shape", {
13580
- description: `REVISE a shape you already committed, instead of deleting it and starting over: pass new verts to move the geometry, condition to reassign it to a different finish tag, role to switch between floor_area / deduct / linear, label to name the room it belongs to, or any combination. Quantities are recomputed from the result \u2014 a role flip alone re-measures (closed area vs open length). The loop this is for: one_click or measure_polygon to commit, view_sheet with overlay:true to LOOK at what landed, then edit_shape to fix the two vertices that overshot into the corridor. label is the per-room reporting seam: detect_rooms already stamps the room number it traced from, so this is how a shape traced by hand \u2014 or one whose room number the sweep read wrong \u2014 joins the same per-room breakdown the Report and the workbook's floor \xD7 room tab group by. Shapes a human affirmed (origin.reviewed) are ink and are refused \u2014 an agent revises its own pencil and nothing else. Agent self-revision is tallied on origin.agent_edits, kept deliberately separate from the human-correction fields. ${COORDS}`,
13680
+ description: `REVISE a shape you already committed, instead of deleting it and starting over: pass new verts to move the geometry, condition to reassign it to a different finish tag, role to switch between floor_area / deduct / linear, label to name the room it belongs to, or any combination. Quantities are recomputed from the result \u2014 a role flip alone re-measures (closed area vs open length). The loop this is for: ${oneClick ? "one_click or " : ""}measure_polygon to commit, view_sheet with overlay:true to LOOK at what landed, then edit_shape to fix the two vertices that overshot into the corridor. label is the per-room reporting seam: ${oneClick ? "detect_rooms already stamps the room number it traced from, so " : ""}this is how a shape traced by hand \u2014 or one whose room number the sweep read wrong \u2014 joins the same per-room breakdown the Report and the workbook's floor \xD7 room tab group by. Shapes a human affirmed (origin.reviewed) are ink and are refused \u2014 an agent revises its own pencil and nothing else. Agent self-revision is tallied on origin.agent_edits, kept deliberately separate from the human-correction fields. ${COORDS}`,
13581
13681
  inputSchema: {
13582
13682
  shape_id: z2.string().describe("Id returned when the shape was committed"),
13583
13683
  verts: z2.array(pointSchema).optional().describe("Replacement geometry (image px): \u22653 vertices for an area shape, \u22652 points for a linear/surface run, \u22651 for a count marker"),
@@ -13588,7 +13688,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
13588
13688
  outputSchema: editShapeOutput
13589
13689
  }, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role, label: a.label })));
13590
13690
  server.registerTool("edit_materials", {
13591
- description: `Add, remove, or patch supporting-materials rows on a condition \u2014 the coverage-rate lines that turn a measured area/length/count into an order quantity (adhesive at N sf/gal, grout at N lf/bag, \u2026), matching the canvas's per-condition Supporting Materials panel. Each row is {name, per, basis, unit, round, note}: quantity = the condition's basis total (area/linear/count/seam_lf) \xF7 per, rounded up to whole purchase units unless round:false. basis "seam_lf" is the one basis that is FIGURED rather than measured: it is the length where two cuts meet on the floor, read off the condition's roll layout (set roll_setup with edit_condition), which is what a heat-weld rod or a carpet seam tape is bought by. A 20-ft-wide room off a 12-ft roll seams once down its length; the same square footage as two 10-ft rooms seams not at all, and no percentage of the area or the perimeter can tell those two jobs apart. Without a roll_setup \u2014 or with no committed floor shapes to lay out \u2014 a seam_lf row reads 0, which is the honest state rather than a guess. condition names an existing OR NEW finish tag (minted on first touch, same as one_click/measure_polygon) \u2014 add alone is enough to seed materials on a condition before you've traced anything. remove/patch target existing row ids from this reply or export_takeoff (takeoff_summary strips materials for a compact quantities-only reply); a bad id 404s the WHOLE call before anything is written, and referencing an id on a tag with no condition yet errors rather than silently minting an empty one. No review gate here \u2014 materials rows are quantity config, not traced geometry, so this edits directly; undo_last reverses a call in one step (the condition's whole materials array, snapshotted before the write, restored verbatim).`,
13691
+ description: `Add, remove, or patch supporting-materials rows on a condition \u2014 the coverage-rate lines that turn a measured area/length/count into an order quantity (adhesive at N sf/gal, grout at N lf/bag, \u2026), matching the canvas's per-condition Supporting Materials panel. Each row is {name, per, basis, unit, round, note}: quantity = the condition's basis total (area/linear/count/seam_lf) \xF7 per, rounded up to whole purchase units unless round:false. basis "seam_lf" is the one basis that is FIGURED rather than measured: it is the length where two cuts meet on the floor, read off the condition's roll layout (set roll_setup with edit_condition), which is what a heat-weld rod or a carpet seam tape is bought by. A 20-ft-wide room off a 12-ft roll seams once down its length; the same square footage as two 10-ft rooms seams not at all, and no percentage of the area or the perimeter can tell those two jobs apart. Without a roll_setup \u2014 or with no committed floor shapes to lay out \u2014 a seam_lf row reads 0, which is the honest state rather than a guess. condition names an existing OR NEW finish tag (minted on first touch, same as ${oneClick ? "one_click/" : ""}measure_polygon) \u2014 add alone is enough to seed materials on a condition before you've traced anything. remove/patch target existing row ids from this reply or export_takeoff (takeoff_summary strips materials for a compact quantities-only reply); a bad id 404s the WHOLE call before anything is written, and referencing an id on a tag with no condition yet errors rather than silently minting an empty one. No review gate here \u2014 materials rows are quantity config, not traced geometry, so this edits directly; undo_last reverses a call in one step (the condition's whole materials array, snapshotted before the write, restored verbatim).`,
13592
13692
  inputSchema: {
13593
13693
  condition: z2.string().describe("Finish tag, e.g. 'CPT-1'"),
13594
13694
  add: z2.array(z2.object({
@@ -13646,14 +13746,14 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
13646
13746
  outputSchema: splitConditionOutput
13647
13747
  }, run("split_condition", (a) => session.splitCondition(a.condition)));
13648
13748
  server.registerTool("undo_last", {
13649
- description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, a delete_shape, an edit_materials call, an edit_condition call, or an RFI verb (create_rfi / resolve_rfi / delete_rfi). Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was, a materials edit's whole array is restored, a condition edit's waste/multiplier pair is restored), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
13749
+ description: `Step back over your OWN last n mutations, newest first \u2014 ${oneClick ? "a committed one_click, a whole detect_rooms sweep" : "a committed measure_polygon"}, an edit_shape, a delete_shape, an edit_materials call, an edit_condition call, or an RFI verb (create_rfi / resolve_rfi / delete_rfi). Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was, a materials edit's whole array is restored, a condition edit's waste/multiplier pair is restored), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
13650
13750
  inputSchema: {
13651
13751
  n: z2.number().int().min(1).max(UNDO_CAP).default(1).describe(`How many steps to reverse (1\u2013${UNDO_CAP})`)
13652
13752
  },
13653
13753
  outputSchema: undoLastOutput
13654
13754
  }, run("undo_last", ({ n }) => session.undoLast(n)));
13655
13755
  server.registerTool("sheet_graph", {
13656
- description: `The plan-set INDEX (#87): every sheet's role (plan / schedule / legend / \u2026, with confidence and the title evidence), the schedule tables found (kind, row count, region \u2014 a schedule CONTINUED across sheets ("\u2026 SCHEDULE \u2014 CONT'D") reads as ONE table, the continuation fragment naming its base in "continues"; rotated column headers are read at their quarter-turn and flagged), every number CORROBORATED as a room (with the stacked room NAME when one exists, the room's BUILDING on multi-building sets, and "corroboration" saying why it counts as a room) plus "unmatched_tags" \u2014 the numbers that are NOT rooms (keynote hexagons, detail markers, dimension fragments, legend rows), each with a reason, listed and never dropped; READ those reasons, one of them may be a room the schedule left out, the detail callouts (3/A-601 \u2192 sheet edges), the set's building designators, every REVISION marker the set carries (text markers "\u03942"/"REV 2" AND drawn deltas \u2014 a bare digit inside a triangle of linework, proven from vector geometry and flagged drawn \u2014 in "revisions", and attached to the schedule row / room tag they sit on), and named indexing gaps in "notes". Built once per document from the text layer and cached. This is how an agent decides WHAT to measure without a human enumerating the rooms: list the rooms here, resolve each with resolve_tag, then measure with one_click/detect_rooms. A scanned set (no text layer) returns available: false \u2014 unavailable, never half-populated. ${COORDS}`,
13756
+ description: `The plan-set INDEX (#87): every sheet's role (plan / schedule / legend / \u2026, with confidence and the title evidence), the schedule tables found (kind, row count, region \u2014 a schedule CONTINUED across sheets ("\u2026 SCHEDULE \u2014 CONT'D") reads as ONE table, the continuation fragment naming its base in "continues"; rotated column headers are read at their quarter-turn and flagged), every number CORROBORATED as a room (with the stacked room NAME when one exists, the room's BUILDING on multi-building sets, and "corroboration" saying why it counts as a room) plus "unmatched_tags" \u2014 the numbers that are NOT rooms (keynote hexagons, detail markers, dimension fragments, legend rows), each with a reason, listed and never dropped; READ those reasons, one of them may be a room the schedule left out, the detail callouts (3/A-601 \u2192 sheet edges), the set's building designators, every REVISION marker the set carries (text markers "\u03942"/"REV 2" AND drawn deltas \u2014 a bare digit inside a triangle of linework, proven from vector geometry and flagged drawn \u2014 in "revisions", and attached to the schedule row / room tag they sit on), and named indexing gaps in "notes". Built once per document from the text layer and cached. This is how an agent decides WHAT to measure without a human enumerating the rooms: list the rooms here, resolve each with resolve_tag, then measure with ${oneClick ? "one_click/detect_rooms" : "measure_polygon on its wall faces"}. A scanned set (no text layer) returns available: false \u2014 unavailable, never half-populated. ${COORDS}`,
13657
13757
  inputSchema: {},
13658
13758
  outputSchema: sheetGraphOutput
13659
13759
  }, run("sheet_graph", () => session.sheetGraph()));
@@ -13676,7 +13776,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
13676
13776
  outputSchema: readSheetTextOutput
13677
13777
  }, run("read_sheet_text", (a) => session.readSheetText(a.sheet, a.region)));
13678
13778
  server.registerTool("find_text", {
13679
- description: `LOCATE a known string on a sheet \u2014 the complement to read_sheet_text (which returns what a region SAYS; this finds WHERE a string you already know sits). Case-insensitive substring match against each pdf.js text run, so a room label split across runs ("OFFICE" then "134" as separate items) needs a find_text call per fragment, or read_sheet_text over a region to see the whole thing joined. Every hit's center feeds straight into one_click as the seed \u2014 the locate-then-trace workflow: find_text the room number, one_click at (or just past) its center. Optionally restrict to a region {x0, y0, x1, y1}; results cap at limit (default 200), with count/truncated telling you exactly how much a tighter region or higher limit would recover. ${COORDS}`,
13779
+ description: `LOCATE a known string on a sheet \u2014 the complement to read_sheet_text (which returns what a region SAYS; this finds WHERE a string you already know sits). Case-insensitive substring match against each pdf.js text run, so a room label split across runs ("OFFICE" then "134" as separate items) needs a find_text call per fragment, or read_sheet_text over a region to see the whole thing joined. ${oneClick ? "Every hit's center feeds straight into one_click as the seed \u2014 the locate-then-trace workflow: find_text the room number, one_click at (or just past) its center." : "Every hit tells you which room a polygon belongs to \u2014 the locate-then-trace workflow: find_text the room number, then " + GATE_HINT + " on that room's wall faces."} Optionally restrict to a region {x0, y0, x1, y1}; results cap at limit (default 200), with count/truncated telling you exactly how much a tighter region or higher limit would recover. ${COORDS}`,
13680
13780
  inputSchema: {
13681
13781
  sheet: z2.string(),
13682
13782
  q: z2.string().min(1).describe("Text to find \u2014 a room number ('134'), a label fragment ('RECEPTION'), a schedule tag ('CPT-1')"),
@@ -13686,7 +13786,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
13686
13786
  outputSchema: findTextOutput
13687
13787
  }, run("find_text", (a) => session.findText(a.sheet, a.q, { region: a.region, limit: a.limit })));
13688
13788
  server.registerTool("view_sheet", {
13689
- description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan, so CROP, DON'T SQUINT: the render downsamples to the px budget (\u22642000 long side), which on an E-size sheet is ~4 sheet pixels per returned pixel \u2014 a full-sheet render finds WHERE things are, and only a tight region crop can tell you what the linework and labels actually say. Never audit a trace or read a dimension off a full-sheet render. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into one_click, measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". marks (#297) burns DISCLOSURE layers into the render, so what a reply names, the picture shows: pass the coordinate lists a tool disclosed \u2014 question: withheld placements (orange ?-circles), struck: rejections a counter-example or luminance gate refused (magenta struck \xD7), ring: reference points like the sweep's own seed (violet double ring). The colors sit deliberately off the common CAD pens so they cannot vanish into color-plotted work. An overlay audit without marks shows only committed ink \u2014 the validation trap where 37 disclosed near-misses read as "it missed them". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
13789
+ description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan, so CROP, DON'T SQUINT: the render downsamples to the px budget (\u22642000 long side), which on an E-size sheet is ~4 sheet pixels per returned pixel \u2014 a full-sheet render finds WHERE things are, and only a tight region crop can tell you what the linework and labels actually say. Never audit a trace or read a dimension off a full-sheet render. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into ${oneClick ? "one_click, " : ""}measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". marks (#297) burns DISCLOSURE layers into the render, so what a reply names, the picture shows: pass the coordinate lists a tool disclosed \u2014 question: withheld placements (orange ?-circles), struck: rejections a counter-example or luminance gate refused (magenta struck \xD7), ring: reference points like the sweep's own seed (violet double ring). The colors sit deliberately off the common CAD pens so they cannot vanish into color-plotted work. An overlay audit without marks shows only committed ink \u2014 the validation trap where 37 disclosed near-misses read as "it missed them". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
13690
13790
  inputSchema: {
13691
13791
  sheet: z2.string(),
13692
13792
  region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional().describe("Crop rect in image px (origin top-left, y down); omit for the full sheet"),
@@ -13714,7 +13814,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
13714
13814
  server.registerTool("annotate", {
13715
13815
  description: `Place an annotation on a sheet \u2014 a note ABOUT the work, never a measurement of it. Types: cloud and highlight take rect:[[x0,y0],[x1,y1]] (a revision cloud around an area, a highlight box over it), text takes at:[x,y], callout takes at:[x,y] plus target:[x,y] (the point its leader aims at), arrow takes from:[x,y] and to:[x,y] (tail and head \u2014 plank/seam direction, the markup flooring drawings use most; #150), bubble takes at:[x,y] plus optional r (a keynote/detail circle carrying centered text), dimension takes from:[x,y] and to:[x,y] (its two measured endpoints) and labels itself with the length between them at the sheet's scale \u2014 drawn as a dimension line with end ticks and the measurement centered. A dimension states a REAL length, so it is the one annotation the scale gate applies to: on an unscaled sheet it refuses exactly like the measure tools (set_scale first) rather than dressing a px figure up as feet. It still touches no quantity \u2014 a dimension is a note about a distance, not a takeoff line item.
13716
13816
 
13717
- Pass condition to attach the note to a finish tag, which is what makes it part of that SCOPE rather than a floating remark: it then wears the condition's colour on the canvas and in the marked-set PDF, and travels with it into the report. The tag is minted on first touch like one_click/measure_polygon, so you can annotate CPT-1 before anything is traced for it. Omit condition for a note about the sheet itself.
13817
+ Pass condition to attach the note to a finish tag, which is what makes it part of that SCOPE rather than a floating remark: it then wears the condition's colour on the canvas and in the marked-set PDF, and travels with it into the report. The tag is minted on first touch like ${oneClick ? "one_click/" : ""}measure_polygon, so you can annotate CPT-1 before anything is traced for it. Omit condition for a note about the sheet itself.
13718
13818
 
13719
13819
  No review gate: the pencil-not-ink rule exists to stop an agent inventing geometry, and a cloud reading "verify substrate" is not geometry. It touches no quantity. ${COORDS}`,
13720
13820
  inputSchema: {
@@ -13939,9 +14039,18 @@ var TOOL_STAGES = {
13939
14039
  "export_dxf"
13940
14040
  ]
13941
14041
  };
13942
- var TOOL_NAMES = Object.freeze(
14042
+ var ALL_TOOL_NAMES = Object.freeze(
13943
14043
  Object.values(TOOL_STAGES).flat().sort()
13944
14044
  );
14045
+ var TOOL_NAMES = Object.freeze(
14046
+ ALL_TOOL_NAMES.filter((n) => !GATED_TOOLS.includes(n))
14047
+ );
14048
+ function stagesFor(oneClick) {
14049
+ if (oneClick) return TOOL_STAGES;
14050
+ return Object.fromEntries(
14051
+ Object.entries(TOOL_STAGES).map(([stage, names]) => [stage, names.filter((n) => !GATED_TOOLS.includes(n))])
14052
+ );
14053
+ }
13945
14054
  var OPENABLE = ["measure", "revise", "handoff"];
13946
14055
  var openToolStageOutput = {
13947
14056
  stage: z3.string().describe("The stage that was opened"),
@@ -13950,20 +14059,22 @@ var openToolStageOutput = {
13950
14059
  closed_stages: z3.array(z3.string()).describe("Stages still closed \u2014 open them here when the work reaches them")
13951
14060
  };
13952
14061
  var STAGED_INSTRUCTIONS = 'TOOL EXPOSURE IS STAGED: only the setup tools are enabled at start. Before measuring, call open_tool_stage {stage:"measure"}; likewise "revise" for edit/annotate/verdict tools and "handoff" for summaries and exports. Opening a stage is instant, idempotent, and never closes anything.';
13953
- function applyStagedTools(server, registered) {
14062
+ function applyStagedTools(server, registered, oneClick = false) {
13954
14063
  const openStages = /* @__PURE__ */ new Set(["setup"]);
14064
+ const stages = stagesFor(oneClick);
13955
14065
  for (const stage of OPENABLE) {
13956
- for (const name of TOOL_STAGES[stage]) registered.get(name)?.disable();
14066
+ for (const name of stages[stage]) registered.get(name)?.disable();
13957
14067
  }
14068
+ const measureVerbs = oneClick ? "one_click, detect_rooms, measure_*, sweeps and derives" : "measure_*, sweeps and derives";
13958
14069
  server.registerTool("open_tool_stage", {
13959
- description: `Enable a stage of this server's tools. Tool exposure is staged to match the takeoff workflow: "setup" (orient: load, scale, read the set) is always enabled; "measure" (commit shapes: one_click, detect_rooms, measure_*, sweeps and derives), "revise" (edit, annotate, verdict-mark, undo), and "handoff" (summaries, exports, the marked set) start closed and open here on demand. Opening a stage is idempotent and never closes another \u2014 the surface only grows. Call it the moment the work reaches a closed stage; the reply lists exactly which tools just became available.`,
14070
+ description: `Enable a stage of this server's tools. Tool exposure is staged to match the takeoff workflow: "setup" (orient: load, scale, read the set) is always enabled; "measure" (commit shapes: ${measureVerbs}), "revise" (edit, annotate, verdict-mark, undo), and "handoff" (summaries, exports, the marked set) start closed and open here on demand. Opening a stage is idempotent and never closes another \u2014 the surface only grows. Call it the moment the work reaches a closed stage; the reply lists exactly which tools just became available.`,
13960
14071
  inputSchema: {
13961
14072
  stage: z3.enum(OPENABLE).describe('Which stage to enable: "measure", "revise", or "handoff"')
13962
14073
  },
13963
14074
  outputSchema: openToolStageOutput
13964
14075
  }, async ({ stage }) => {
13965
14076
  try {
13966
- const names = TOOL_STAGES[stage];
14077
+ const names = stages[stage];
13967
14078
  const enabled = [];
13968
14079
  for (const name of names) {
13969
14080
  const tool = registered.get(name);
@@ -14028,7 +14139,7 @@ function nameTheStageInRefusals(server) {
14028
14139
  // package.json
14029
14140
  var package_default = {
14030
14141
  name: "opentakeoff-mcp",
14031
- version: "0.9.71",
14142
+ version: "0.9.73",
14032
14143
  mcpName: "io.github.Kentucky-ai/opentakeoff",
14033
14144
  type: "module",
14034
14145
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -14045,7 +14156,7 @@ var package_default = {
14045
14156
  prepublishOnly: "npm run typecheck && npm test && npm run build",
14046
14157
  typecheck: "tsc --noEmit",
14047
14158
  "check:tool-count": "node scripts/check-tool-count.mjs",
14048
- test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
14159
+ test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
14049
14160
  },
14050
14161
  dependencies: {
14051
14162
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -14104,6 +14215,7 @@ var package_default = {
14104
14215
  // server.ts
14105
14216
  function buildServer(session = new Session(), opts = {}) {
14106
14217
  const staged = opts.stagedTools ?? process.env.OPENTAKEOFF_MCP_STAGED_TOOLS === "1";
14218
+ const oneClick = oneClickEnabled(opts.oneClick);
14107
14219
  const server = new McpServer2({ name: "opentakeoff", version: package_default.version }, {
14108
14220
  // Served to every client at initialize — the discipline that makes agent
14109
14221
  // takeoffs land as reviewable work instead of a bare numbers report.
@@ -14111,19 +14223,20 @@ function buildServer(session = new Session(), opts = {}) {
14111
14223
  "OpenTakeoff: quantity takeoff on construction plan PDFs.",
14112
14224
  "A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
14113
14225
  "1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
14114
- "2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row). A COUNT takeoff of value-annotated device marks (GRDs, fixtures, equipment \u2014 the tag-over-value pattern) starts with count_marks {commit: true}: the whole census in one deterministic call, then audit its withheld entries \u2014 reach for the agent-driven per-mark tools only where it refuses or withholds.",
14226
+ oneClick ? "2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row). A COUNT takeoff of value-annotated device marks (GRDs, fixtures, equipment \u2014 the tag-over-value pattern) starts with count_marks {commit: true}: the whole census in one deterministic call, then audit its withheld entries \u2014 reach for the agent-driven per-mark tools only where it refuses or withholds." : "2. Commit shapes under finish-tag conditions (measure_polygon / measure_line with `condition`; a room's polygon is its wall faces \u2014 read them with get_sheet_vectors, confirm on view_sheet, and take the finish tag from the room's own schedule row via resolve_tag). A COUNT takeoff of value-annotated device marks (GRDs, fixtures, equipment \u2014 the tag-over-value pattern) starts with count_marks {commit: true}: the whole census in one deterministic call, then audit its withheld entries \u2014 reach for the agent-driven per-mark tools only where it refuses or withholds.",
14115
14227
  "3. DERIVE what follows from the rooms instead of re-measuring it: derive_base for base LF (perimeter \u2212 the door openings YOU state), derive_transitions for the line where two finishes meet. Both read committed floor shapes, so they come after step 2 and their output is audited in step 4 like anything else.",
14116
14228
  "4. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
14117
14229
  "5. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone.",
14118
14230
  "A floor split across sheets at a MATCH LINE: there is no stitch verb, deliberately \u2014 joining and aligning a match line is human judgment in the canvas (a sloppy join silently skews every seam-crossing quantity). Measure each member sheet as its own surface and tell the user a seam-crossing room needs their stitch in the app; never approximate one by combining sheets yourself.",
14119
- "WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row, count_marks and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse.",
14231
+ `WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. ${oneClick ? "detect_rooms, " : ""}symbol_sweep, sweep_schedule_row, count_marks and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse.`,
14120
14232
  "When the DRAWINGS are the problem \u2014 a schedule row the plan never draws, a room the schedule has no row for, a finish called out two ways \u2014 raise it with create_rfi (annotate a cloud at the conflict first and link it) instead of describing it in prose: the question lands numbered in the estimator's RFI register and prints in the marked set, pending their acceptance before it goes to the architect.",
14233
+ ...oneClick ? [] : [GATE_NOTE],
14121
14234
  ...staged ? [STAGED_INSTRUCTIONS] : []
14122
14235
  ].join("\n")
14123
14236
  });
14124
- const registered = registerTools(server, session);
14237
+ const registered = registerTools(server, session, { oneClick });
14125
14238
  registerResources(server, session);
14126
- if (staged) applyStagedTools(server, registered);
14239
+ if (staged) applyStagedTools(server, registered, oneClick);
14127
14240
  return server;
14128
14241
  }
14129
14242
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.71",
3
+ "version": "0.9.73",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
- "description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
6
+ "description": "OpenTakeoff MCP server drive the takeoff engine from your MCP client over stdio.",
7
7
  "license": "Apache-2.0",
8
8
  "engines": {
9
9
  "node": ">=20"
@@ -17,7 +17,7 @@
17
17
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
18
18
  "typecheck": "tsc --noEmit",
19
19
  "check:tool-count": "node scripts/check-tool-count.mjs",
20
- "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
20
+ "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
21
21
  },
22
22
  "dependencies": {
23
23
  "@modelcontextprotocol/sdk": "^1.12.0",