opentakeoff-mcp 0.9.18 → 0.9.19

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
@@ -109,7 +109,7 @@ includes document text, shape vertices, or result payload content.
109
109
  | `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
110
110
  | `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. |
111
111
  | `one_click` | One-Click Area at (x, y): flood fill bounded by the plan linework, traced, vertices snapped. Pass `condition` to commit; `role: "deduct"` subtracts. |
112
- | `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. Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible), never dropped silently. Pass `condition` to commit every detected room. |
112
+ | `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. 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. |
113
113
  | `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
114
114
  | `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
115
115
  | `derive_base` | **Base LF from committed rooms**: for every floor shape of a source condition, commits a linear base run tracing that room's boundary, quantified net of the door openings YOU state per room (`{shape_id, lf}` — your claim, recorded on `origin.derived`; the tool never guesses). All-or-nothing; one undo step. |
@@ -123,8 +123,8 @@ includes document text, shape vertices, or result payload content.
123
123
  | `edit_condition` | Set a condition's **waste %**, **×N multiplier**, **height_ft** (the H knob `measure_surface` quantifies against), and **roll_setup** (the roll-goods opt-in: seams figured, cuts packed, the reply echoes the order — cuts, `order_lf`, rolls, `order_qty` — and `export_report`'s `roll_goods` block carries the same rows; `null` opts out) — the knobs that turn measured quantities into order quantities. Resolves an **existing** finish tag or errors — a typo must not mint an empty condition. No review gate; one `undo_last` step restores the knobs verbatim. |
124
124
  | `export_report` | The **computed Report document** — `opentakeoff.report.v1`, the same JSON the canvas Report exports: gross + waste-adjusted quantities, the computed materials **buy list** per condition plus the project-wide roll-up, per-sheet base subtotals, and scale provenance. The contract for pricing consumers — `export_takeoff` carries materials as config rows, `takeoff_summary` strips them. Inline, and to disk with `path`. |
125
125
  | `import_takeoff` | **The way back in**: load a `takeoff_canvas.v1` file (a prior `export_takeoff`, or the app's own save) through the SAME merge rules as the app's Sheet-menu import — finish-tag identity joins conditions (this session's knobs win), new ids append, duplicates skip (idempotent re-import), this session's calibration wins per sheet. Resume, extend, or audit. |
126
- | `export_marked_pdf` | The **marked-up planset** — the deliverable. Writes a distribution-ready PDF: a legend cover (per-condition totals, swatches, by-sheet breakdown) plus every sheet that carries work, vector-copied from the source with shapes, hatches, per-shape quantity chips, and annotations burned in — built by the same module as the canvas's MARKED SET button. Machine-traced shapes are disclosed as pending human review on the document itself. Default path: `<plan> - marked set.pdf` next to the plan. Works without `@napi-rs/canvas`. |
127
- | `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, and review state in one compact read — the ids `edit_shape`/`delete_shape` assume you have, without pulling the whole `export_takeoff` payload. Filters by sheet/condition narrow; empty is a result, not an error. |
126
+ | `export_marked_pdf` | The **marked-up planset** — the deliverable. Writes a distribution-ready PDF: a legend cover (per-condition totals, swatches, by-sheet breakdown) plus every sheet that carries work, vector-copied from the source with shapes, hatches, per-shape quantity chips, and annotations burned in — built by the same module as the canvas's MARKED SET button. Machine-traced shapes are disclosed as pending human review on the document itself, and the cover states where the finish tags came from (`Finish assignment: N schedule-resolved · N agent-asserted · …`, plus any rooms the last assign run withheld). Default path: `<plan> - marked set.pdf` next to the plan. Works without `@napi-rs/canvas`. |
127
+ | `list_shapes` | The **mid-session inventory**: every committed shape's id, sheet, condition, role, quantities, review state, and assignment verdict (`schedule` \| `asserted` — where its finish tag came from) in one compact read — the ids `edit_shape`/`delete_shape` assume you have, without pulling the whole `export_takeoff` payload. Filters by sheet/condition narrow; empty is a result, not an error. |
128
128
  | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored, a condition edit's waste/multiplier pair restored. A whole `detect_rooms` sweep is **one** step. |
129
129
  | `annotate` | Place a note ABOUT the work — cloud/highlight (`rect`), text (`at`), callout (`at` + `target`), **arrow** (`from` + `to` — plank/seam direction), **bubble** (`at` + optional `r` — keynote circle, centered text). Attach to a condition and it wears that scope's colour on the canvas and in the marked set. No review gate: notes are not geometry. |
130
130
  | `list_annotations` | Every annotation with its condition RESOLVED to a finish tag, coordinates back in image px; filter by sheet/condition. `unattached` counts the link_annotation candidates. |
@@ -2292,6 +2292,12 @@ var Session = class _Session {
2292
2292
  conditions = [];
2293
2293
  markups = [];
2294
2294
  shapes = [];
2295
+ /** The last assign-from-schedule run's unresolved rooms (0.9.18) — what the
2296
+ * marked-set cover discloses as withheld. Replaced per assign run, cleared
2297
+ * with the rest of the session on a non-merge load_plan. Seeds ride
2298
+ * normalized so the export-time staleness drop can test them against
2299
+ * committed rings in verts_norm space. */
2300
+ scheduleWithheld = [];
2295
2301
  /** Newest-last. Capped at UNDO_CAP; the oldest entry falls off the front. */
2296
2302
  journal = [];
2297
2303
  seq = 0;
@@ -2331,6 +2337,7 @@ var Session = class _Session {
2331
2337
  this.file = null;
2332
2338
  this.filePath = null;
2333
2339
  this.nextOrd = 1;
2340
+ this.scheduleWithheld = [];
2334
2341
  this.journal = [];
2335
2342
  this.pendingCommits = [];
2336
2343
  } else if (this.docs.has(base)) {
@@ -2734,6 +2741,7 @@ var Session = class _Session {
2734
2741
  return c;
2735
2742
  }
2736
2743
  commit(s, tag, role, vertsPx, computed, origin) {
2744
+ if (origin?.actor === "agent" && !origin.assignment) origin = { ...origin, assignment: { source: "asserted" } };
2737
2745
  const c = this.conditionFor(tag);
2738
2746
  const shape = {
2739
2747
  id: uid("shp"),
@@ -2830,6 +2838,16 @@ var Session = class _Session {
2830
2838
  * judge and nothing commits anyway. */
2831
2839
  async detectRooms(name, opts) {
2832
2840
  const s = this.sheet(name);
2841
+ const assign = !!opts.assignFromSchedule;
2842
+ let graph = null;
2843
+ if (assign) {
2844
+ if (s.upp == null) throw new UserError(this.scaleGate(s));
2845
+ graph = await this.ensureGraph();
2846
+ if (!graph.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, not empty.");
2847
+ if (!graph.tables.some((t) => t.kind === "room-finish")) {
2848
+ throw new UserError("No room-finish schedule in the working set \u2014 load_plan the schedule sheet with merge: true, or pass condition to commit every room under one tag.");
2849
+ }
2850
+ }
2833
2851
  const mask = await this.maskWithLayers(name, opts.layers);
2834
2852
  if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
2835
2853
  const minAreaSf = opts.minAreaSf ?? 5;
@@ -2839,7 +2857,8 @@ var Session = class _Session {
2839
2857
  const num2 = (sp.str || "").trim().split(/\s+/).find((tok) => ROOM_LABEL_RE.test(tok));
2840
2858
  if (num2) labels.push({ str: num2, bbox: sp });
2841
2859
  }
2842
- const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0 };
2860
+ const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0, unresolved: 0 };
2861
+ const unresolved = [];
2843
2862
  const byRing = /* @__PURE__ */ new Map();
2844
2863
  const order = [];
2845
2864
  for (const lb of labels) {
@@ -2907,21 +2926,42 @@ var Session = class _Session {
2907
2926
  return null;
2908
2927
  }
2909
2928
  const perimeter_lf = round2(c.perimPx * upp);
2929
+ let tag = opts.condition;
2930
+ let assignment;
2931
+ if (assign) {
2932
+ const hit = this.floorTagFor(graph, c.label);
2933
+ if ("reason" in hit) {
2934
+ withheld.unresolved++;
2935
+ unresolved.push({ label: c.label, reason: hit.reason, area_sf, perimeter_lf, seed: [round1(c.seed[0]), round1(c.seed[1])] });
2936
+ return null;
2937
+ }
2938
+ tag = hit.tag;
2939
+ assignment = { source: "schedule", room_tag: c.label, surface: "FLOOR", schedule_sheet: hit.sheet };
2940
+ }
2910
2941
  let shape_id;
2911
- if (opts.condition) {
2912
- shape_id = this.commit(s, opts.condition, opts.role, c.ring, { area_sf, perimeter_lf }, {
2942
+ if (tag) {
2943
+ shape_id = this.commit(s, tag, opts.role, c.ring, { area_sf, perimeter_lf }, {
2913
2944
  method: "one_click_v1",
2914
2945
  actor: "agent",
2915
2946
  seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
2916
2947
  reviewed: false,
2917
2948
  ...c.hatch ? { hatch_filtered: true } : {},
2918
- ...c.gap ? { gap_bridged_px: c.gap } : {}
2949
+ ...c.gap ? { gap_bridged_px: c.gap } : {},
2950
+ ...assignment ? { assignment } : {}
2919
2951
  }).id;
2920
2952
  }
2921
- return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
2953
+ return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id, condition: tag } : {} };
2922
2954
  }).filter((r) => r !== null);
2923
2955
  this.flushCommits("detect_rooms");
2924
- const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.implausible;
2956
+ if (assign) {
2957
+ this.scheduleWithheld = unresolved.map((u) => ({
2958
+ sheet_id: s.key,
2959
+ label: u.label,
2960
+ reason: u.reason,
2961
+ seed_norm: [u.seed[0] / s.widthPx, u.seed[1] / s.heightPx]
2962
+ }));
2963
+ }
2964
+ const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.implausible + withheld.unresolved;
2925
2965
  return {
2926
2966
  detected: rooms.length,
2927
2967
  rooms,
@@ -2930,8 +2970,11 @@ var Session = class _Session {
2930
2970
  ...withheld,
2931
2971
  ...upp != null ? { min_area_sf: minAreaSf } : {}
2932
2972
  },
2973
+ // assign mode always states the answer, empty array included: [] is the
2974
+ // positive claim "every detected room resolved against its own row"
2975
+ ...assign ? { unresolved } : {},
2933
2976
  ...s.detected?.multi ? { multiple_scales: true } : {},
2934
- ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable.` } : {},
2977
+ ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable${assign ? `, ${withheld.unresolved} unresolved against the schedule (see unresolved[])` : ""}.` } : {},
2935
2978
  ...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
2936
2979
  };
2937
2980
  }
@@ -3085,6 +3128,7 @@ var Session = class _Session {
3085
3128
  ...x.height_ft !== void 0 ? { height_ft: x.height_ft } : {},
3086
3129
  nverts: x.verts_norm.length,
3087
3130
  reviewed: x.origin?.reviewed === true,
3131
+ ...x.origin?.assignment ? { assignment: x.origin.assignment.source } : {},
3088
3132
  ...x.origin?.agent_edits ? { agent_edits: x.origin.agent_edits } : {}
3089
3133
  })),
3090
3134
  count: rows.length
@@ -3164,7 +3208,13 @@ var Session = class _Session {
3164
3208
  verts_norm: vertsPx.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
3165
3209
  computed,
3166
3210
  ...role === "surface_area" ? { height_ft: Number(cur.height_ft) || heightFor() } : {},
3167
- ...cur.origin ? { origin: { ...cur.origin, agent_edits: (cur.origin.agent_edits ?? 0) + 1 } } : {}
3211
+ ...cur.origin ? { origin: {
3212
+ ...cur.origin,
3213
+ agent_edits: (cur.origin.agent_edits ?? 0) + 1,
3214
+ // a reassign onto a different tag is the agent choosing the finish —
3215
+ // keeping "schedule" (and its citation) past that point would be a lie
3216
+ ...patch.condition !== void 0 && cur.origin.assignment?.source === "schedule" ? { assignment: { source: "asserted" } } : {}
3217
+ } } : {}
3168
3218
  };
3169
3219
  this.record({ op: "edit", tool: "edit_shape", before });
3170
3220
  const changed = [
@@ -3534,6 +3584,24 @@ var Session = class _Session {
3534
3584
  counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
3535
3585
  };
3536
3586
  }
3587
+ /** The FLOOR finish a room's own schedule row states — assign-from-schedule's
3588
+ * per-room resolver (0.9.18): resolveTag's chain narrowed to the one surface
3589
+ * a floor takeoff commits. Refusal over guessing, per room: an unresolved
3590
+ * tag returns resolveTag's own reason; a resolved row with no FLOOR cell
3591
+ * says so; a compound cell ("CPT-1/VCT-1") is ambiguous — committing the
3592
+ * whole room's SF under a two-finish literal would assert an area split the
3593
+ * schedule never stated. Compound detection is narrow ("/", ",", " OR "),
3594
+ * so a hyphenated code never trips it. BASE/WALL are deliberately ignored:
3595
+ * those are derive_base's and measure_surface's measures. */
3596
+ floorTagFor(g, tag) {
3597
+ const res = resolveTag(g, tag);
3598
+ if (res.status !== "resolved") return { reason: res.reason };
3599
+ const floor = res.finishes.find((f) => f.surface === "FLOOR");
3600
+ const code = floor?.code.trim();
3601
+ if (!floor || !code) return { reason: `schedule row ${res.tag} states no FLOOR finish` };
3602
+ if (/[/,]|\bOR\b/i.test(code)) return { reason: `ambiguous: floor cell "${code}" names more than one finish with no stated split` };
3603
+ return { tag: code, sheet: floor.source.sheet };
3604
+ }
3537
3605
  async resolveRoomTag(tag) {
3538
3606
  if (!tag || !tag.trim()) throw new UserError('Pass a room tag, e.g. resolve_tag { tag: "134" }.');
3539
3607
  const g = await this.ensureGraph();
@@ -3695,6 +3763,7 @@ var detectedRoom = z.object({
3695
3763
  area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
3696
3764
  perimeter_lf: z.number().optional().describe("Scaled mode: traced perimeter in LF"),
3697
3765
  shape_id: z.string().optional().describe("Scaled mode: id of the committed shape, when condition was passed"),
3766
+ condition: z.string().optional().describe("The finish tag this room committed under \u2014 the passed condition, or in assign mode the FLOOR finish its own schedule row states. Present exactly when shape_id is"),
3698
3767
  area_px2: z.number().optional().describe("Preview mode (no scale): raw area in px\xB2"),
3699
3768
  perimeter_px: z.number().optional().describe("Preview mode (no scale): raw perimeter in px")
3700
3769
  });
@@ -3707,8 +3776,16 @@ var detectRoomsOutput = {
3707
3776
  duplicate: z.number().int().describe("Flooded to a region another label already claimed \u2014 counted once, never twice"),
3708
3777
  bubble: z.number().int().describe("Labels whose every clean flood was their own label BUBBLE (ring bbox \u2248 label bbox \u2014 plans box their room numbers). Scale-free, so it guards unscaled previews too"),
3709
3778
  implausible: z.number().int().describe("Enclosed, clean, non-bubble, but smaller than min_area_sf \u2014 a door swing or wall cavity rather than a room"),
3779
+ unresolved: z.number().int().describe("Assign mode: rooms the schedule could not answer for (no row, no FLOOR cell, or a compound cell) \u2014 withheld into unresolved[], never committed under a guess. Always present; 0 outside assign mode"),
3710
3780
  min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
3711
3781
  }).describe("What detection skipped and why \u2014 a withheld room is a question the caller can ask; a silently dropped one is a hole in a bid"),
3782
+ unresolved: z.array(z.object({
3783
+ label: z.string().describe("The room tag as drawn"),
3784
+ reason: z.string().describe(`WHY the schedule could not answer \u2014 resolveTag's own reason, "states no FLOOR finish", or "ambiguous: \u2026" for a compound cell`),
3785
+ area_sf: z.number().describe("The room's real traced area \u2014 withheld from committing, not from reporting"),
3786
+ perimeter_lf: z.number(),
3787
+ seed: z.tuple([z.number(), z.number()]).describe("The flood seed (image px) \u2014 once the estimator answers, one_click here with the stated condition commits it")
3788
+ })).optional().describe("Assign mode only, empty array included: [] is the positive claim that every detected room resolved against its own schedule row"),
3712
3789
  note: z.string().optional().describe("Human-readable summary of what was withheld, when anything was"),
3713
3790
  multiple_scales: z.literal(true).optional().describe("Several DISTINCT scale notes on this sheet (#153) \u2014 rooms inside an enlarged viewport may be figured at the wrong scale"),
3714
3791
  warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
@@ -3791,7 +3868,7 @@ var exportTakeoffOutput = {
3791
3868
  measure_role: z.enum(["floor_area", "deduct", "linear", "surface_area", "count"]),
3792
3869
  verts_norm: z.array(point).describe("Vertices normalized to sheet dims (0\u20131)"),
3793
3870
  computed: z.object({ area_sf: z.number().optional(), perimeter_lf: z.number().optional(), count: z.number().optional() }).passthrough().describe("count shapes carry {count} alone; every other role carries area_sf + perimeter_lf"),
3794
- origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
3871
+ origin: z.object({}).passthrough().optional().describe("Provenance: method (manual|one_click_v1), actor (omitted=human, 'agent'=MCP/automation), reviewed (human affirmed at an explicit gate), assignment (where the finish tag came from \u2014 {source: 'schedule', room_tag, surface, schedule_sheet} when the room's own schedule row decided it, {source: 'asserted'} when the agent chose; stamped on every agent commit), and correction fields (edited, edited_before_create, copied, proposed_verts_norm, edits)")
3795
3872
  }).passthrough()),
3796
3873
  markups: z.array(z.unknown()),
3797
3874
  sheet_group: z.array(z.unknown()),
@@ -3838,6 +3915,7 @@ var listShapesOutput = {
3838
3915
  height_ft: z.number().optional().describe("surface_area shapes \u2014 the height they were quantified at"),
3839
3916
  nverts: z.number().int(),
3840
3917
  reviewed: z.boolean().describe("true = human-affirmed ink, refused by every agent mutation"),
3918
+ assignment: z.enum(["schedule", "asserted"]).optional().describe(`Where the finish tag came from: "schedule" = resolved from the room's own schedule row, "asserted" = the agent chose it. origin.assignment in export_takeoff carries the citation. Absent on human canvas shapes`),
3841
3919
  agent_edits: z.number().int().optional().describe("Present when the agent has revised this shape")
3842
3920
  })),
3843
3921
  count: z.number().int()
@@ -4735,7 +4813,7 @@ function invertPixels(cv) {
4735
4813
  ctx.fillRect(0, 0, cv.width, cv.height);
4736
4814
  ctx.restore();
4737
4815
  }
4738
- async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, coverTitle = "Marked Set", units = "imperial" }) {
4816
+ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, rfis = [], conditions, getPage, loadPdfData, company, clientInfo, credit = null, provenance = null, coverTitle = "Marked Set", units = "imperial" }) {
4739
4817
  const M = units === "metric";
4740
4818
  const uA = (sf) => M ? sf * 0.09290304 : sf;
4741
4819
  const uL = (lf) => M ? lf * 0.3048 : lf;
@@ -4825,6 +4903,10 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
4825
4903
  }
4826
4904
  }
4827
4905
  draw(`${marked.length} marked sheet${marked.length === 1 ? "" : "s"} \xB7 ${markedShapes.length} takeoff item${markedShapes.length === 1 ? "" : "s"} \xB7 quantities net of deducts, waste-adjusted where noted`, { x: 52, y: metaY, size: 9.5, font, color: muted });
4906
+ if (provenance) {
4907
+ metaY -= 12;
4908
+ draw(provenance, { x: 52, y: metaY, size: 9.5, font, color: muted });
4909
+ }
4828
4910
  let y = metaY - 34;
4829
4911
  const rows = conditionTotals(conditions, markedShapes).filter((r) => r.shape_count > 0);
4830
4912
  draw("CONDITIONS", { x: 52, y, size: 9, font: bold, color: muted });
@@ -5146,6 +5228,20 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, r
5146
5228
  }
5147
5229
 
5148
5230
  // src/marked.ts
5231
+ function assignmentDisclosure(shapes, withheld = []) {
5232
+ const agent = shapes.filter((s) => s.origin?.actor === "agent");
5233
+ const live = withheld.filter((w) => !shapes.some((s) => s.sheet_id === w.sheet_id && (s.measure_role === "floor_area" || s.measure_role === "deduct") && s.verts_norm.length >= 3 && pointInPoly(w.seed_norm[0], w.seed_norm[1], s.verts_norm)));
5234
+ if (!agent.length && !live.length) return null;
5235
+ const schedule = agent.filter((s) => s.origin?.assignment?.source === "schedule").length;
5236
+ const asserted = agent.filter((s) => s.origin?.assignment?.source === "asserted").length;
5237
+ const pending = agent.filter((s) => s.origin?.reviewed !== true).length;
5238
+ const parts = [];
5239
+ if (schedule) parts.push(`${schedule} schedule-resolved`);
5240
+ if (asserted) parts.push(`${asserted} agent-asserted`);
5241
+ if (pending) parts.push(`${pending} pending human review`);
5242
+ if (live.length) parts.push(`${live.length} room${live.length === 1 ? "" : "s"} withheld, unresolved against the schedule`);
5243
+ return parts.length ? `Finish assignment: ${parts.join(" \xB7 ")}` : null;
5244
+ }
5149
5245
  var buildMarkedSetPdf2 = buildMarkedSetPdf;
5150
5246
  async function exportMarkedPdf(session, opts) {
5151
5247
  const { file, filePath } = session;
@@ -5199,6 +5295,7 @@ async function exportMarkedPdf(session, opts) {
5199
5295
  getPage,
5200
5296
  loadPdfData,
5201
5297
  credit,
5298
+ provenance: assignmentDisclosure(session.shapes, session.scheduleWithheld),
5202
5299
  coverTitle: "OpenTakeoff \xB7 Marked Set"
5203
5300
  });
5204
5301
  const outPath = path3.resolve(opts.path ?? path3.join(path3.dirname(filePath), `${base} - marked set.pdf`));
@@ -5421,7 +5518,7 @@ function registerTools(server, session) {
5421
5518
  return session.setScale(a.sheet, a);
5422
5519
  }));
5423
5520
  server.registerTool("one_click", {
5424
- description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. 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. Without a scale it returns px-only quantities with a warning and commits nothing. role "deduct" makes the committed shape subtract. ${COORDS}`,
5521
+ description: `One-Click Area: click inside a room (image px) and the plan's vector linework bounds it \u2014 flood fill, contour trace, vertices snapped to true PDF endpoints. 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. Without a scale it returns px-only quantities with a warning and commits nothing. 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}`,
5425
5522
  inputSchema: {
5426
5523
  sheet: z2.string(),
5427
5524
  x: z2.number(),
@@ -5435,10 +5532,11 @@ function registerTools(server, session) {
5435
5532
  outputSchema: oneClickOutput
5436
5533
  }, 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 })));
5437
5534
  server.registerTool("detect_rooms", {
5438
- 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. 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. With the sheet's scale set, returns area_sf/perimeter_lf per room; pass condition to commit every detected room under that finish tag (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. ${COORDS}`,
5535
+ 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. 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. With the sheet's scale set, returns area_sf/perimeter_lf per room. 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}`,
5439
5536
  inputSchema: {
5440
5537
  sheet: z2.string(),
5441
- condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
5538
+ condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use). Mutually exclusive with assign_from_schedule"),
5539
+ 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`),
5442
5540
  role: roleSchema,
5443
5541
  return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
5444
5542
  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."),
@@ -5446,7 +5544,12 @@ function registerTools(server, session) {
5446
5544
  layers: layersFilterSchema
5447
5545
  },
5448
5546
  outputSchema: detectRoomsOutput
5449
- }, run("detect_rooms", (a) => 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 })));
5547
+ }, run("detect_rooms", (a) => {
5548
+ if (a.assign_from_schedule && a.condition !== void 0) {
5549
+ 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).");
5550
+ }
5551
+ 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 });
5552
+ }));
5450
5553
  server.registerTool("measure_polygon", {
5451
5554
  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}`,
5452
5555
  inputSchema: {
@@ -5661,7 +5764,7 @@ function registerTools(server, session) {
5661
5764
  outputSchema: findTextOutput
5662
5765
  }, run("find_text", (a) => session.findText(a.sheet, a.q, { region: a.region, limit: a.limit })));
5663
5766
  server.registerTool("view_sheet", {
5664
- description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan: full-sheet overview first, then tight crops at higher px until dimension strings and room labels read cleanly. 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". 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}`,
5767
+ 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". 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}`,
5665
5768
  inputSchema: {
5666
5769
  sheet: z2.string(),
5667
5770
  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"),
@@ -5798,7 +5901,7 @@ function registerResources(server, session) {
5798
5901
  // package.json
5799
5902
  var package_default = {
5800
5903
  name: "opentakeoff-mcp",
5801
- version: "0.9.18",
5904
+ version: "0.9.19",
5802
5905
  mcpName: "io.github.Kentucky-ai/opentakeoff",
5803
5906
  type: "module",
5804
5907
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -5875,8 +5978,8 @@ function buildServer(session = new Session()) {
5875
5978
  "OpenTakeoff: quantity takeoff on construction plan PDFs.",
5876
5979
  "A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
5877
5980
  "1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
5878
- "2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`).",
5879
- "3. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals.",
5981
+ "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).",
5982
+ "3. 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).",
5880
5983
  "4. 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."
5881
5984
  ].join("\n")
5882
5985
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.18",
3
+ "version": "0.9.19",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",