Skip to content

MCP Server - Do Not Use This Software!! - #2614

Draft
tyeth wants to merge 347 commits into
Snapmaker:mainfrom
tyeth:startup/base
Draft

tyeth wants to merge 347 commits into
Snapmaker:mainfrom
tyeth:startup/base

Conversation

@tyeth

@tyeth tyeth commented Sep 7, 2026 •

Copy link
Copy Markdown

No warranty expressed or implied. Use at your own risk. Ensure the safety of yourself and others and property at all times, following local laws and safety requirements, use Personal Protective Equipment, and an Enclosure for the CNC.
You must always be next to the machine ready to perform an emergency stop or switch the power off.

@tyeth tyeth changed the title MCP Server MCP Server - Do Not Use This Software!! Sep 7, 2026
tyeth and others added 29 commits September 19, 2026 17:44
…ed file

Two of the six operator approvals in the 2026-09-19 session were spent
re-deriving G-code from refusal prose: "put G53 on its own line" and "declare
the distance mode". The validator knew exactly what the file should say and
made the agent write it out again anyway.

suggestGcode() returns a corrected draft for the three edits that need no
decision: an inline G53 split onto its own line, a missing G90 added first,
and a trailing G54 when the file would otherwise leave the controller in the
machine workspace. Anything needing a judgement - which frame an undeclared
file meant, whether a G92 was intended - returns null; a suggestion is only
offered when it is certain, and it is re-validated before being handed over.

validate_gcode reports it as suggested_gcode / suggested_changes, and a
staging refusal appends it under "Re-submit this instead". The submitted file
is still never edited: this is the program the agent should have written, not
a rewrite of theirs.

The epilogue is judged on the SPLIT program, because an inline G53 selects
nothing - which is the bug being fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ools

traverse_xy and move_z exist because a hand-written transit keeps getting the
same things wrong: on 2026-09-12 one reached the confirm page with no frame
at all, and on 2026-09-19 one was rejected for an undeclared distance mode,
restaged, and then left the controller in the machine workspace for the rest
of the session. Both were staged through submit_gcode_job, which had no
opinion about what the file was for.

A cnc file that is nothing but transport is now refused at staging, naming
the tool that emits the declared, frame-restoring, landmark-checked version.

"Transport" is drawn tightly enough that real work is never caught by it: the
file must move in XY only or in Z only - exactly what the two tools can
express between them - with no spindle, no probing, no arcs, no B rotation,
no G92, no relative motion, and no more than eight motion lines. A toolpath,
a slicer export or anything that moves in XY and Z together is left alone.

The validator gains motionAxes and usesProbing to say this precisely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
clearance_z has always meant "minimum safe toolhead Z, tool length included by
whoever set it". Conflating the obstacle's height with the fitted tool has two
costs: the number has to be re-stated on every tool change, which nobody does,
so it ends up set for the longest thing ever fitted - and it therefore sits at
the machine ceiling. The rotary-axis landmark declares 328, the homing height,
because it had to cover a ~73 mm touch probe on top of the hardware, and the
README records what that cost: job 34d787bdb2d7 lost its last op when a hop at
320 OUTSIDE the rotary footprint was refused against it.

Landmarks now carry clearanceBasis. 'physical' states the top of the obstacle
itself, so the tool can be added at check time and the number stays true.
set_landmark takes obstacle_top_z for that, and keeps clearance_z as the
legacy form; giving both is refused, since they are the same number measured
to different things.

Nothing is re-interpreted: every stored record loads as 'toolhead' and is
enforced exactly as before. Reading 328 as a physical height would demand a
toolhead Z of 400, so the migration has to be the operator's word, not ours.
The check itself is unchanged in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stating an obstacle's height physically only works if the server can say what
is above it. Nothing reports which tool is in the collet: the tool setter
measures one when asked, and the operator declares the longest bit in use and
the touch probe's effective length once each. Any of the three could be fitted
right now.

resolveToolProtrusion takes the longest of them, with provenance. A
measurement therefore only ever LENGTHENS the requirement - shortening a
clearance on the strength of a measurement that may predate a tool change
nobody mentioned is exactly the risky reading. When nothing at all is known
the answer is null, never a guess, and the note names the tools that would fix
it.

run_tool_setter now records derivedBitLengthMm - what the tool actually
protrudes, as opposed to the length declared to plan the descent - so the
measured candidate exists at all. Measurements written before this carry no
derived length and are ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A physically stated clearance now demands obstacle top + how far the fitted
tool hangs below the toolhead + a 5 mm margin, resolved through the one place
that reads the stored tool state (clearanceContext). With the touch probe
fitted a 250 mm obstacle reproduces exactly the 328 that used to be hard-coded
into the landmark; with a 2 mm engraving bit it asks for 257, which is where
the machine gets its working room back.

A physical obstacle whose requirement CANNOT be computed - nothing known about
the tool - is impassable, not passable, and the refusal names the two tools
that would fix it. A legacy 'toolhead' clearance is enforced exactly as
before and is never inflated by the tool a second time.

The direct-move guard had its own copy of the comparison, which is the one
that actually fired on 2026-09-19 (move_and_capture -> obstaclesOnPath):
it now shares the same requirement and the same float-noise epsilon, so the
327.999994-against-328 refusal is fixed on that path too, not just in the
procedure planners.

Every checkMotion caller passes the live tool length; traversePlan stays pure
and takes it from its caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o restate

An agent reading get_stored_state saw a landmark with "clearance_z: 328" and
no way to know that 328 is a toolhead height with a touch probe baked into it
rather than anything about the rotary.

Every landmark now reports requiredToolheadZ - what its clearance actually
demands of the toolhead with the tool currently resolved - and says in words
where that number came from. Records still on the legacy basis carry the
restatement they want, and the summary lists them together with the resolved
tool protrusion, its source and the margin, so the migration is visible in one
read instead of having to be inferred landmark by landmark.

set_landmark echoes the same advice when it writes a legacy clearance, at the
moment someone is thinking about that obstacle.

Nothing changes in what is enforced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One number did both jobs: where a procedure hops, retreats and ends, AND the
lowest Z any XY move could happen at. It sits at the ceiling because of the
second job - the rotary landmark declares clearance 328 since that number had
to cover a fitted touch probe, so the floor had to rise to meet it (README,
job 34d787bdb2d7, where a hop at 320 OUTSIDE the rotary footprint was refused).

With clearances now stating obstacle heights and the tool added at check time,
the two separate. safeTraverseZ stays the park height at 328; motionFloorZ is
new, defaults to 320, is clamped to never exceed the park height, and is what
law 2 is enforced against - traverse_xy, move_and_capture, survey_bed and the
tool setter's travel to the setter. Hops, retreats, probe start heights and
every procedure's ending are untouched.

The traverse-height exemption from crossing checks stays REMOVED: a hop at the
floor is checked against every stored box exactly like any low segment, which
is what makes a floor below the park height safe at all.

planTraverseXy also stops planning its segments at the park height and plans
them at the height the toolhead is actually at (snapped up only by the
heartbeat's float noise). That was harmless while the floor WAS the park
height and wrong the moment transport is allowed lower - it would have checked
a corridor the toolhead was not in.

Residual risk, stated plainly: 8 mm less blind protection for anything on the
bed with no landmark. mcpMotionFloorZ is the one setting that reverts it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_stored_state.limits reports motionFloorZMm alongside safeTraverseZMm, so a
fresh session reads both numbers rather than inferring one from the other.

README law 2 becomes "at or above the motion floor", says plainly that the
floor is not the park height, records why it could only drop once clearances
stopped carrying tool length, and states what it costs: 8 mm less blind
protection for anything on the bed with no landmark.

Law 4 now describes obstacle_top_z, how the tool protrusion and margin are
added, that an unknown tool makes a physical obstacle impassable rather than
passable, and that legacy records are enforced unchanged until re-stated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A camera bootstrap has to solve against features whose machine coordinates are
already known, and it needs POINTS, not just a line: the rotary axis fixes X
and Z, and naming an end fixes Y as well.

set_probe_geometry gains rotary_tailstock_y and rotary_chuck_face_y, which
also settle which end is the chuck - "the non-chuck end" was a guess in the
2026-09-19 session and cost several poses. rotaryAxisPoints() returns whichever
of them is stated as a full 3D point.

set_tool_setter_config gains disc_diameter_mm: nothing in the tool setter
needs it, but a circle of known size in a frame is an absolute scale
constraint that does not depend on the pose solution being right.

No landmark topZ field is needed for this - a landmark stated with
obstacle_top_z already carries the physical height of its own top face.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator law, 2026-09-19: the camera is not a rig constant. It can sit
differently after every power cycle, be knocked, be re-aimed, or be a
different camera entirely. So camera geometry is session state.

CameraModel carries where the camera is (offset and rotation relative to the
toolhead), what it sees (intrinsics, with k1 only when the targets constrain
it), the Z band its poses actually covered, its residuals, and a state that
says whether any of it may still be used.

judgeCameraModel decides that on evidence rather than age: a different camera
or a different resolution needs a full bootstrap; a reconnect needs a
verification, because nothing about a camera survives a power cycle on trust;
and a model that has only ever agreed with the data it was fitted to has
demonstrated nothing, so it stays unverified until it passes a pose that was
not in its own fit. An unnamed device is unknown, not different - an MJPEG URL
names none.

A new solve never overwrites the old one: the previous model is kept
superseded with its residuals, so "was the camera moved between these two
jobs?" stays answerable afterwards.

Nothing consumes the model yet; this is the type, the store and the
judgement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The question the 2026-09-19 session could not ask was "where must the toolhead
go to see this?". It guessed from a remembered "the camera looks -X, 90-150
mm", moved 30 mm to find out, and spent three operator approvals establishing
a sign.

cameraGeometry answers it from the model: viewPose returns the toolhead
position that puts a machine point dead centre, whatever direction the camera
looks and whatever the tilt, with the standoff it implies. machineToPixel and
pixelToMachine are inverses of each other on a STATED Z plane - the plane is
always an argument, never a guess, because a single frame cannot say how far
away what it sees is, and assuming one plane for a feature on another is what
read ~4x wrong on the board. fovAt measures the real footprint across the
frame's own corners, which is what makes an overlapping survey computable at
all. jacobianAt regenerates the legacy 2x2 so visual_servo is untouched - that
matrix was always this model linearised at one Y and one Z, it just had no
model to come from.

Everything works in machine coordinates, which is why one rigid transform is
enough: the gantry carries X and Z and the platform carries Y, but the machine
frame is the tool relative to the work, and in it the camera centre is the
toolhead position plus a fixed offset.

Nothing converts through a model that is not verified, a pixel outside the
region the fit constrains is refused rather than silently converted, a point
behind the camera is reported as such, and a toolhead Z outside the band the
poses covered is flagged as extrapolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tools around the model. get_camera_model reports it with its state and,
when it is not usable, why and which tool fixes it. set_camera_model stores a
solve from the bootstrap script, fingerprinting the camera from a frame taken
right then, and refuses a rotation whose columns are not an orthonormal frame -
a rotation that is not a rotation skews every conversion silently.

A stored model is always UNVERIFIED: agreeing with the data it was fitted to
demonstrates nothing. verify_camera_model is what makes it usable - it predicts
where a target of known machine coordinates should appear at the current
toolhead position, compares that with where it actually appears, and records
the residual in pixels and millimetres. Beyond tolerance the model stays
unverified and the note says plainly that the camera has probably been moved
and that no pixel should be converted through it meanwhile.

It is also the first camera call of any session, which is the point: a model
is bound to a connection epoch, so a reconnect demands this check before
anything trusts the geometry again. No motion - position the toolhead first.

requireCameraModel is the gate the pixel-consuming tools will use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two staged procedures, one approval each, because the second cannot be planned
until someone has looked at the first.

"search" is the step that needs no calibration to mean anything, which is why
it comes first: a serpentine grid at the park height across the X band the
camera could be looking from, bracketing the tool setter, whose machine XY is
known exactly. Which frames contain the setter, compared with the toolhead XY
of those frames, gives the camera offset INCLUDING ITS SIGN with no prior
assumption whatever. The 2026-09-19 session reached the same grid only after
forty minutes of single poses and an assumed offset that pointed the wrong
way.

"poses" visits the poses that coarse offset implies and sweeps Z from the park
height to the motion floor with XY STATIONARY - a vertical baseline, not a
diagonal through unknown space - capturing at every stop. Targets at different
heights over that baseline are what turn a flat pixels-per-millimetre figure
into perspective.

The planning is pure and tested. Transport is always at the park height and
the head returns there before the next XY move; a descent is segmented and
crash-guarded like any other. The camera looks into keep-outs on purpose, but
the toolhead does not enter one: a pose it cannot reach is dropped with a
reason, a sweep that would descend into a box keeps the stops above it, and
nothing is ever quietly adjusted. For a descent every obstacle is judged as a
volume - the 'crossing' exemption is for probing inside a footprint, not for
looking at it.

A bootstrap with no target of known machine coordinates measures nothing, so
that is a refusal naming the tools that would fix it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…odel

scripts/camera_bootstrap.py turns a bootstrap frame set into the payload
set_camera_model takes. Ten unknowns - the camera's offset from the toolhead,
its orientation, and a pinhole's fx/fy/cx/cy - fitted against sightings of
points whose machine coordinates the machine already knows: the tool setter's
centre and plate top, the rotary axis line and its named ends.

It refuses to call a fit a solve when it is not one. Fewer than six
observations, or fewer than three distinct poses, is a refusal that says why:
the offset and the focal length trade off against each other from a single
standoff, which is exactly what the Z sweep exists to separate.

Detection is optional and deliberately conservative - a wrong detection is
worse than none, because the fit believes it - so hand marks are a first-class
input and always win. k1 is never fitted here: a distortion term from a
handful of points would be noise wearing a physical name, and the cost is
carried honestly by central_region, computed from how much of the frame the
targets actually covered.

--self-test recovers a known model from synthetic sightings, then does it
again with the marks jittered by 1.5 px, because a solver that only works on
exact input is not a solver for this.

The half-turn case in the Rodrigues inverse is handled explicitly: a camera
looking straight down at a bed IS a half-turn from the identity, so the naive
form divides by zero on the most ordinary mounting there is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
plan_view_pose answers "where must the toolhead go to see this machine point?"
from the verified model, so the direction the camera looks and the sign of its
offset are measured facts rather than something remembered. It returns the
toolhead XY, the standoff, the field of view on the plane named, and whether
the toolhead Z asked for is outside the band the model was solved over. On
2026-09-19 an assumed "the camera looks -X, 90-150 mm" pointed the wrong way
and cost three operator approvals to find out.

visual_servo takes an optional plane_z. Given one, and a verified model, the
2x2 is derived from the model at this exact pose instead of read from a stored
calibration - the stored matrix IS that model linearised at one Y and one Z,
and unlike the model it cannot say whether it is still about the camera that
is plugged in. It is derived as an ordinary entry, so the sign check, the
depth-plane cross-check and the series memory are all untouched, and the
warnings say which was used and on what plane.

With neither, the refusal now names both routes rather than only the stored
matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Seamless" is a relationship between the grid pitch and the field of view, and
until the camera model existed nothing knew the field of view - so the pitch
was a number somebody chose and the frames overlapped, or did not, by luck.

survey_bed takes overlap_fraction and plane_z. Given an overlap, the pitch is
computed from the model's real footprint on that plane: pitchForOverlap is
pure and tested, including that a 30% pitch really does leave about 30%
shared. An absurd overlap is clamped rather than producing a pitch of nothing,
and pitch_mm still caps the result.

It needs a VERIFIED model and says so - the field of view is the whole basis
of the number, and guessing it is exactly what this replaces. plane_z is
stated, never inferred: a frame cannot tell how far away what it sees is.

The envelope the operator approves carries the pitch and where it came from,
including a warning when the Z is outside the band the model was solved over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2026-09-19 session wanted camera shots at several heights. It had to stage
that as a separate job, which then could not be withdrawn, and the agent ended
up telling its operator in prose not to approve it.

survey_bed takes z_levels: up to six full passes of the grid, highest first,
one approval for the series. Each level is entered with XY STATIONARY, so the
survey is a stack of flat passes and never a diagonal through unknown space,
and every level must be at or above the motion floor unless the operator has
explicitly said otherwise - the refusal names the levels that are too low
rather than silently clamping them.

Frames carry their own Z and the index lists the levels, so a mosaic can be
built per plane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The point is not prettiness. Once every frame is placed where the model says
it belongs, "the hole is at mosaic pixel (u, v) on plane Z" becomes a lookup
through the index's own affine - instead of what the 2026-09-19 session was
reduced to, which was inferring a position from one frame and a remembered
scale that turned out to have the wrong sign.

One mosaic per Z pass, written beside the frames, with its machine bounding
box, its millimetres per pixel, the pixel-to-machine affine and the fraction
of the canvas no frame covered. Machine Y runs UP the picture and the affine
says so.

Each frame is drawn by inverse mapping over its own footprint: for every
mosaic pixel it could cover, the model is asked which of its pixels that
machine point projects to. Hole-free where a forward splat would leave gaps,
and it costs the mosaic's area rather than frames times pixels. A whole-bed
mosaic is coarsened rather than refused - the scale gives way, not the
coverage.

Seams are not blended. A hard edge at the right coordinates says more than an
average of two disagreeing views, and where two frames disagree that is
evidence about the camera rather than something to smooth over.

Without a verified model there is no mosaic and the index says so, naming
camera_bootstrap; the frames themselves are still all there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Where a second frame claims a mosaic pixel the first already drew, the two
saw the same ground from different poses. Comparing them costs nothing during
the render and is the only free evidence available about whether the model
still describes the camera.

A pure brightness difference is not disagreement - auto-exposure does that
between any two frames - so it is subtracted before judging. What is left is
structural: the same ground landing in different places, which means the
camera was knocked, re-aimed or swapped since the model was solved. Past the
threshold the model is marked unverified there and then, with a note naming
verify_camera_model and camera_bootstrap.

Too little shared ground is inconclusive, never a pass and never a
condemnation: the note says so and points at overlap_fraction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Law 2 becomes a floor: XY transport at or above machine Z320, with the park
height (Z328) named separately as where procedures hop, retreat on abort and
end. The skill says plainly that the floor could only drop once clearances
stopped carrying tool length, that there is still no exemption for being high,
and what it costs - 8 mm less blind protection for anything on the bed with no
landmark.

Law 4 becomes the obstacle's own height: state obstacle_top_z, the server adds
the fitted tool and a margin, a measurement only ever lengthens the
requirement, and an unknown tool makes a physically stated obstacle
impassable rather than passable. With the probe fitted a 250 mm obstacle still
demands 328; with a 2 mm bit it demands 257.

The position-of-record section gains the case that cost the 2026-09-19
session: a controller left in the machine workspace, which no amount of
waiting fixes, is recognised after three beats and cured by restore_work_frame
- not by a re-home, which only works by accident because it emits a G54 on the
way. And the hygiene that stops it: every machine-frame job hands the frame
back, which is the real reason to use traverse_xy and move_z rather than
writing the file yourself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The skill used to state the camera's offset as fact: "the camera looks -X,
seeing roughly 90-150 mm to the toolhead's -X side", with arithmetic to match.
On 2026-09-19 an agent followed it, went to toolhead X 290 for a feature at
X~170, moved +30 mm to check, watched the workpiece slide further out of
frame, and was corrected to "260 is about the max" - three operator approvals
to establish a sign that one measurement settles.

That section is gone. In its place: the camera is session state, so
verify_camera_model is the first camera call of every session, with a table of
what each outcome means and which tool fixes it; camera_bootstrap solves the
geometry from nothing in two stages, the first of which needs no calibration
to mean anything; and plan_view_pose answers "where must the toolhead go to
see this" from the measured model. The old number survives only as the
cautionary tale, quoted as the thing that was wrong.

The survey section leads with "survey first, single poses second" - the same
session found what it wanted in the first grid it ran, after forty minutes of
single poses - and documents overlap_fraction, z_levels, the machine-indexed
mosaic and the seams as the drift check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two new dry-run scenarios. "view-unfamiliar-workpiece-and-map-its-top" checks
that a model solved two days ago is verified before any pose arithmetic, that
a survey beats a chain of single poses, and that a position is read off the
mosaic rather than inferred from one frame and a remembered scale.
"camera-knocked-and-controller-stuck-in-g53" puts both of the 2026-09-19
faults in one prompt and grades diagnosing them separately, curing the
position with restore_work_frame rather than a re-home, and not computing a
pose until the camera model is verified again.

Every eval gains two assertions: transport at or above the motion floor with
the park height kept distinct, and no assumed camera offset, field of view or
viewing direction.

Eval 0 loses the hint it used to carry - "the toolhead camera looks -X and
sees ~90-150 mm to its -X side". Handing an agent that number is exactly the
mistake being fixed, and grading it on following the hint would have rewarded
the behaviour that cost the session.

The visual-alignment description no longer advertises Y-keyed calibration as
the mechanism.

These are the scenarios, not a rerun: the fresh-agent evals across Opus,
Sonnet and Haiku still need running against the new skills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six tools were added across this stack and none of them was in TOOLS.md, which
is the file an agent reads to find out what exists. Discoverability is not a
footnote here: the 2026-09-19 session hand-wrote transit gcode while
traverse_xy sat unused, and reached a camera grid only after forty minutes of
single poses.

Adds restore_work_frame beside the direct-motion tools, a "the camera model"
section that leads with the camera being session state rather than a rig
constant, and the new survey_bed arguments - overlap-derived pitch, z_levels,
the machine-indexed mosaic and the seams as a drift check.

The plan doc records what was built and, as plainly, what was not: the
fresh-agent eval rerun, and any exercise against the live machine, which was
not reachable from this session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Live 2026-09-19 on the box: the KB2040 U2IF bridge enumerated on USB, Blinka
resolved its board id, and the first digitalio.DigitalInOut() never returned -
the bridge was wedged. The transport reported "GPIO monitor produced no ready
line within 30000 ms" and retried quietly for eighteen minutes. Nobody saw
anything: the machine was not connected, so the Workspace pills were not on
screen, and the only other signal was connected:false among forty fields.

Probing was never unsafe - assertChannelReady already refuses without a live
feed - but finding out when you try to probe is not good enough for a sensor
whose whole job is to be watching.

Two changes, both about saying what is true:

The monitor now emits progress as it comes up (imports done, each pin
configured), so a ready timeout can be classified. "Blinka loaded and the
bridge answered as KB2040_U2IF, but configuring the first pin never returned -
the board is enumerated on USB but not servicing requests; UNPLUG AND REPLUG
it, nothing in software can reset it" is a different fault from "the
interpreter produced nothing, check the venv and adafruit-blinka", and the old
message covered both.

And the status carries a sentence: how long it has been down, how many
attempts, what it blocks (every probing procedure and tool-setter run, because
the overtravel tripwire cannot be armed without it) and that nothing needs
restarting once the physical cause is fixed. It rides on get_stored_state,
which is the call every session is told to make first.

The judgement is pure and tested, including that a wedged bridge is never told
to reinstall Blinka and a failed import is never told to replug the board.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g a human

The previous commit called this a wedged bridge and said to unplug and replug
it, "nothing in software can reset it". That was wrong, and the operator said
so: it is a previous instance not releasing its reference.

Confirmed on the box. `lsusb -t` showed the KB2040's HID interface as
Driver=[none] instead of Driver=usbhid - a libusb claim that a monitor killed
by the 30 s ready timeout never reattached. The board still enumerated and
`import board` still resolved KB2040_U2IF in about a second, which is why this
looked like dead hardware; only the first digitalio.DigitalInOut() blocked.
USBDEVFS_RESET on the device node fixed it outright:

  before   If 2  Class=Human Interface Device, Driver=[none]
  after    If 2  Class=Human Interface Device, Driver=usbhid
  pin configuration   40 s+ hang -> 1.17 s, D2 idle as documented

No root needed - the node carries a plugdev ACL - so the transport now does it
itself. On a ready timeout that carries the leaked-claim signature (board
loaded, no pin configured) it resets the bridge and retries, and the error says
what it did. Node has no ioctl and this transport already requires a Python
interpreter, so the reset runs there.

The scan is deliberately narrow: only known U2IF vendors, and only a device
whose HID interface actually has no driver bound. Resetting USB devices at
large is not this module's business. Non-Linux hosts skip it rather than
failing - the retry is still what recovers.

Verified against the real device in its healthy state: the helper finds the
KB2040 and correctly declines to reset it, "HID interface still bound to its
kernel driver".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Probe feed: say what a dead sensor bridge means, and recover it automatically
Fix workspace jog steps using hidden saved values
The .zip files under .claude/skills are the packages uploaded to another
agent. They were built on 2026-09-15 and never rebuilt, so while this branch
carries the corrected camera doctrine in SKILL.md, the packaged copies still
told an agent "the camera looks -X, seeing roughly 90-150 mm to the toolhead's
-X side" - the exact folklore that cost the 2026-09-19 session three operator
approvals and sent the toolhead the wrong way.

Rebuilt from the sources in this branch. The visual-alignment package also
gains scripts/camera_bootstrap.py, which was never in it, and the motion-rules
package picks up the two new eval scenarios.

Built with a sorted walk so the archives are deterministic: a diff between two
builds means a real content change rather than a reordered directory listing.
Checked by reading back out of the archives - the old arithmetic appears zero
times as an instruction and survives only inside the paragraph that quotes it
as the thing that was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tyeth and others added 30 commits October 17, 2026 17:17
… links

Operator rulings of 2026-10-17 (issues #241 and #242).

#241 - "if we've got to top z there's no approval for move at top z":
- safeZPolicy.ts (pure, tested): traverse_xy, the XY step of
  goto_tool_change_position (its no-op Z step skipped) and an UP-only move_z
  to no higher than the traverse height run on the call when the position of
  record is verified/heartbeat, the machine is homed, the head is already at
  or above mcpSafeTraverseZ and the route passed the landmark/travel checks.
- The job record is kept and marked approval.source "policy" (dashboard, log,
  timing, stop_gcode_job unchanged); the result says
  executed_without_approval: true, or staged_because when it staged.
- Setting mcpTraverseWithoutApprovalAtSafeZ (default on; env
  LUBAN_MCP_TRAVERSE_WITHOUT_APPROVAL_AT_SAFE_Z) on the MCP Server pane.
- start_gcode_job's direct-step runner is extracted (executeDirectStep) and
  reused, so the policy path executes exactly what an approved job would.

#242 - probe_sequence retreated to Z328 between every station (~59 s of
every 98 s station on the FR4 re-registration):
- Opt-in local_links {clearance_z, max_hop_mm?} (sequenceLinks.ts, pure,
  tested): hops between stations at a stated toolhead machine Z, each
  <= max_hop_mm (cap 60, the surface scans' bound), checked against every
  landmark/keep-out at that height; after a march the probe retreats to its
  start and the next hop raises only to max(clearance, current Z). First hop
  and final exit stay at the traverse height. clearance_z may be a bounded
  program reference to an earlier top touch.
- cnc-probing gains a re-registration recipe (one top touch when the plane
  is known flat, inside probe_circle on a known hole, one edge pair + one
  perpendicular touch; opposite pairs only for pre-travel calibration) and
  cnc-motion-rules / planning.md / TOOLS.md / README describe both changes;
  skill zips rebuilt.

Closes #241
Closes #242

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e-local-hops

Feature: Run top-Z transport on the call and add probe_sequence local links
Fix: Add adaptive probing and explicit setter datum evidence
tools/toolsetter.ts now imports clearanceContext, landmarks, safeZPolicy,
traversePlan and tools/gcode for the #241 top-Z policy leg of
goto_tool_change_position; the isolated toolDatum fixture refused them as
unstubbed (9 failures on the startup/base run 39075606273). The pure modules
load as themselves, the server-bound ones are inert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Test: Stub the tool-change park policy dependencies in toolDatum.test
The operator asked for "F400 to 10 mm above the copper, then F60 for the
last 10 mm" as one staged job. move_z took one feed for the whole series
and the hand-written file was refused as pure transport, so the standard
fast-then-creep approach had no sanctioned path.

- z_targets entries are a number or {z, feed_rate?} (plain numbers keep
  working); a step without its own feed runs at the call's feed_rate.
  Resolution lives in the new pure moveZPlan.ts (resolveZTargets): every
  feed is clamped to MOVE_Z_FEED and every clip is REPORTED - on the
  confirm page (`; CLAMPED:` header lines) and in the result
  (feed_clamps), via the new procedureLimits.clampReported.
- The confirm-page header shows each step's feed and each step's gcode
  carries its own F word (the controller's modal F survives between
  files). decideSafeZRaise is unaffected: it judges machine targets only.
- The pure-transport refusal (validator.TRANSPORT_REFUSAL) was what
  refused the issue's file (Z only, three rapids, no spindle); it now
  points at the per-step move_z form.
- Tool description, docs/TOOLS.md and the cnc-motion-rules canonical
  calls (zip rebuilt) describe the new form.

Tests: tests/moveZPlan.test.ts (registered in run.ts); validator.test
proves the issue's exact file is pure transport and the refusal names
the new form.

Closes #240

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closes #142. set_enclosure_light / set_enclosure_fan {percent 0-100 |
state on/off} and get_enclosure_status are first-class MCP calls: no
motion, no confirm page, and never a hand-typed M-code. ConnectionManager
gains readEnclosureReport / setEnclosureLevel, which make the same channel
calls the Workspace enclosure widget makes (SSTP GET/POST
/api/v1/enclosure, SACP getEnclosreInfo / setEnclosureLight /
setEnclosureFan, the serial M1010 S3/S4) and return the outcome instead of
emitting it; SstpHttpChannel's enclosure poll and setters now share two
promise helpers so both paths use one endpoint and one parser.

mcp/enclosure.ts reads every channel's raw report into one status shape
(present, light/fan percent, door detection, door open, reason) and parses
the requested level; the set tools read the status first and refuse when
the machine reports no enclosure, then read it back and report whether the
readback matches. Tests cover the argument and status mapping; TOOLS.md
and the cnc-motion-rules confirm-page table (a no-motion row) are updated
and the skill zip rebuilt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Issue #187: an agent staged a floor probe of an internal pocket deeper than
the probe's exposed stylus, and only the march's abort at its no-contact
limit kept the probe body off the rim. The probe's tool length (the setter
contact offset) says nothing about how far the tip reaches below the body.

- stylusReach.ts (pure): checkStylusReach judges the deepest commanded
  toolhead Z against a stated rim (rim_z_machine / alias surface_z_machine,
  the toolhead Z at which the tip touches the highest surface under the
  body) using the stored probe_stylus_exposed_mm (tip bottom to the first
  body shoulder) minus the ball minus reach_margin_mm (named limit
  STYLUS_REACH_MARGIN_MM, default 2, 0-10). A refusal carries the numbers
  and the toolhead Z below which the body lands; no stored reach yields
  "stylus reach unknown: body collision not checked" on the result and the
  confirm page; a stored reach with no rim is reported as rim-unknown.
- rotaryGeometry: stylusGeometry() / stylusReachForPlan(); get_stored_state
  geometry.stylus exposes reachMm, bodyDiameterMm, tipDiameterMm.
- Planners checked at staging and reporting result.stylusReach: probe_point,
  probe_vector, probe_sequence (deepest descend / march limit), surface
  path/grid (floor), probe_circle (side-contact Z), probe_corner,
  probe_wall_follow and probe_trace_perimeter (z_machine); the three rim
  arguments are on every schema (RIM_ARG_SCHEMA) and resolve as references
  inside probe_program.
- tests/stylusReach.test.ts registered in run.ts.
- Skill text: cnc-probing jig facts row, the "probe tool length != tip
  length" rule and planning guidance; cnc-probing.zip rebuilt; TOOLS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
submit_gcode_job and validate_gcode only took an inline `gcode` string,
so a real CAM file (295 KB on 2026-10-15, 870 KB / 46 720 lines on
2026-10-17) could not be staged through the MCP at all, and the one
attempt that was made submitted a truncated copy that reached the
confirm page with nothing but an "M3 without M5" warning (#237).

Both tools now take the program from exactly one of three intakes, and
every route is validated, frame-resolved and staged identically:

- `gcode_path`: a file the Luban server reads from an allow-listed
  directory - `<data dir>/mcp-jobs-inbox/` (created on demand, reported
  by get_stored_state.jobIntake and on every intake result/refusal)
  plus one optional extra directory (`mcpJobInboxDir` /
  LUBAN_MCP_JOB_INBOX_DIR, Settings -> MCP Server -> Job files). Paths
  outside the roots, any `..` segment, and symlinks/junctions resolving
  out of a root are refused after realpath; only regular files pass.
- `upload_id`: begin_gcode_upload {name, total_bytes, sha256} /
  append_gcode_chunk {upload_id, seq, text} (ordered, idempotent on an
  identical repeat, refused on a different one or a gap) /
  finish_gcode_upload (byte count and SHA-256 verified; a mismatch
  discards the upload). validate_gcode peeks, submit_gcode_job consumes
  on success. Untouched uploads expire after 30 min; at most 8 open.
- `gcode`: inline, for short programs.

Limits are named in procedureLimits.ts with their reasons: the 4 MiB
program cap is McpServer's request-body cap, so no intake exceeds what
one call could already carry; chunks are 256 KiB.

Every job now carries a program identity (bytes, SHA-256, lines, motion
lines, first/last motion line, last line) on jobManager.describe and in
a "Program identity" table on the confirm page, so the operator can
tell a staged program is the complete file.

The validator reports spindle.onAtEnd and spindleRefusal() turns a
program whose spindle is on and never turned off, or still running at
the end, into a refusal at both tools, with what was received.

Tests: tests/gcodeIntake.test.ts (path allow-listing incl. an injected
symlink escape, chunk assembly and hashing, expiry, truncation refusal,
identity lines, and synthetic 300 KB / 870 KB work-frame CAM files
staged by path and by upload giving the same report and identity),
registered in tests/run.ts. Docs: TOOLS.md and cnc-motion-rules §7/§8
(zip rebuilt).

Closes #237

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The geometry store modelled the rotary axis as one rotary_axis_x /
rotary_axis_z_physical for every Y, so probe_program's seeded axis.x handed
every op the same X whatever its Y station - on a rotary yawed ~1.2 deg off
machine Y that is 5 mm of X over 250 mm (issue #155).

- rotaryAxisLine.ts (pure): the axis as a line through measured points
  {x, y, z_physical, source, measured_at} at two or more Y stations; derived
  yaw_deg / pitch_deg, the x / z at any Y, the seeded axis namespace and its
  per-Y re-evaluation. Two stations fix the line, more are least-squares
  fitted with the worst residual reported; stations at the same Y refuse.
- rotaryGeometry.ts: rotary_axis_points stored (config / env
  LUBAN_MCP_ROTARY_AXIS_POINTS); the legacy scalars become the line's values
  at the reference Y (derived once points exist, refused by hand); a store
  with only the scalars loads unchanged as yaw 0 flagged skew unmeasured.
  get_stored_state -> geometry.rotary carries referenceY / yawDeg / pitchDeg /
  skew / points and geometry.rotaryAxisPoints the stations with provenance.
- set_probe_geometry: rotary_axis_point (one station, add or restate at its
  Y) and rotary_axis_points (the list; null clears), each with source and
  measured_at - the rotary-axis check's side-pair X and opposite-face Z at
  the Y it measured them, fed to the store where it is measured.
- probe_program: axis.x / axis.z_physical / axis.z_contact are evaluated at
  each op's own Y station (programOps.opStationY reads it from the op's
  arguments) before that op's references resolve; the reference grammar is
  unchanged (dotted paths, no axis.x_at(y) call - the choice is documented
  in probeProgram.ts). The job record phase op-<id>-axis says which Y and
  what the axis came to; the confirm page states the line and its skew.
- rotate_b: insideSweptCylinder measures the tip's perpendicular distance
  from the yawed, pitched line at the toolhead's live Y (envelopeChecks.ts
  SweptAxis / distanceFromSweptAxis); the scalar form is the zero-slope case.
- procedureLimits: MAX_ROTARY_AXIS_POINTS (8) with its reason.
- Tests: tests/rotaryAxisLine.test.ts (two points -> yaw / pitch, x and z at
  y, three-point residual, refusals, unmeasured fallback, namespace per Y,
  cylinder check straight vs yawed, point validation) registered in run.ts;
  opStationY cases in programOps.test.ts.
- Skills: cnc-probing references/rotary-axis.md (record each station,
  axis.* per op Y, the cylinder check on the line), cnc-probing SKILL.md
  store table and geometry paragraph, cnc-motion-rules section 2 (the axis
  is a measured line) and the reference grammar; both zips rebuilt. MCP
  README / TOOLS.md / CAMERA_SURVEY_PLAN.md updated to match.

Refs #155

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…177)

Frames from the traverse height (machine Z328) cannot resolve a contact
point or a pocket wall. The program `capture` op now takes an optional
`z` - a MEASURED or operator-stated toolhead machine Z, in a program a
bounded reference to an earlier top touch, never a guess.

- programOps.captureOpArgs accepts z (finite, 0..MAX_STATED_MACHINE_Z_MM);
  the new pure captureMotion lists the op's moves (viewing hop + descent
  column, or the column alone where the head stands) for envelopeChecks,
  so the staging check covers the column against landmarks and the
  program's keep-out volumes; the runner re-checks from the live position.
- probeProgram: z may be a bounded reference (validated, previewed at the
  mid-point, REFUSED outside its bounds at run time like any other op
  number). After the viewing hop the runner does the guarded descent as a
  sequence descend step does - probing.guardedDescent, new: from the
  verified position, descendInSegments to DESCENT_GUARD_MM above, then
  1 mm sensor-checked steps at the coarse feed, any contact aborts -
  waits for the machine to settle, frames (result.statedZ, result.descent),
  then raiseToTop STRAIGHT back to the traverse height (a held probe stops
  the program), so the op still ends raised. Needs the probe channel.
- programEnvelope.describeCaptureOp enumerates the column (the fast
  segments, every guarded 1 mm step at F100) and the raise as the moves
  they are; the program header counts captures that descend.
- DESCENT_GUARD_MM moves to procedureLimits (pure, for the describer);
  probeSequence re-exports it for its importers.
- Tool description, docs/TOOLS.md, cnc-probing and cnc-camera-operations
  skill text (zips rebuilt).

Tests: programOps.test (z validation, captureMotion, a column into a
keep-out volume refused), programEnvelope.test (the rendered column, Z
extents 215..328, header count).

Closes #177

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Feature: Accept large G-code jobs from an inbox path or chunked upload
Feature: Store the rotary axis as a measured line with yaw and pitch
Closes #218. The MCP HTTPS listener runs from mkcert files, but a browser
that has never seen the local CA refuses the origin (Chrome offers no
exception) and system notifications on the confirm and dashboard pages
need a trusted HTTPS origin. Two plain-HTTP routes on the MCP server fix
that: GET /ca.crt serves the public CA certificate
(application/x-x509-ca-cert, attachment luban-mcp-rootCA.crt) and
GET /trust is an HTML page with the download link, the CA fingerprint and
install steps for Windows, macOS, Linux, iOS and Android browsers; a
request for either over HTTPS redirects to the HTTP listener.

The CA file is the new optional setting mcpHttpsCaFile (env
LUBAN_MCP_HTTPS_CA; api-mcp GET/POST, the Settings -> MCP Server pane with
i18n) or, when unset, rootCA.pem under `mkcert -CAROOT` (one execFile,
cached, warmed at service start). The key is never served: a file that
parses as a private key - or carries one beside the certificate - is
refused, as is a non-CA leaf certificate, and the page warns when the HTTPS
server certificate was not issued by the CA it serves. The pane links to
/trust once HTTPS is configured and shows which CA file would be served;
the job dashboard shows the link from status.json. caTrust.test.ts covers
the setting precedence, the mkcert cache, PEM classification, the refusals,
the redirect and the page; README (settings table and HTTPS section) and
TOOLS.md document the routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ca-trust

Feature: Add enclosure light/fan tools and serve the HTTPS CA trust page
…166)

"1 mm x/y traverse but 5 mm lunge": the operator found the 5 mm descent
segments risky. The law is not changed silently - 5 mm stays the default
and the cap - but the segment length becomes a named, bounded limit a
procedure can LOWER per call where the column is not proven clear.

- procedureLimits.DESCENT_SEGMENT_MM: Bounded {default 5, min 1 (the
  march segment), max 5}, with the reasons written next to it: a descent
  waits on the controller's ok per segment, the crash guard is
  asynchronous and latches within the segment in flight, so 5 mm is the
  most the head travels after a hit; 1 mm everywhere would make a 300 mm
  descent from home ~300 settle-verified moves for no gain on a proven
  column. probing.ts re-exports it; TRACE_COARSE_STEP_MM shares the cap.
- probing.descendInSegments takes options.segmentMm (clamped into the
  limit) and reports the length it used; guardedDescent passes it on.
- descent_segment_mm on probe_sequence / probe_program `descend` steps
  (per step), on the surface scans (the approach to station 1) and on a
  capture at a stated Z. Every clip is REPORTED: `; CLAMPED:` on the page
  and the plan carries the requested value.
- Every confirm page states the segment length it will use: sequence and
  surface pages the step's / scan's own, the other describers
  (circle, corner, outline, trace, wall follow, tool setter, probe cam)
  the named default instead of a literal "5 mm"; the program header says
  "<= 5 mm segments, or the shorter descent_segment_mm an op states".
- cnc-motion-rules law 5 gains the descent-segment paragraph (why 5 is
  the default, when to lower it); cnc-probing names the argument; both
  zips rebuilt. docs/TOOLS.md and the tool schemas describe it.

Tests: procedureLimits.test (the bound, clip reporting, segment counts),
programOps.test (capture descent_segment_mm, needs a z), programEnvelope
.test (the page states 2 mm / reports a clipped 12); probeInspection
fixtures carry the new plan fields.

Closes #166

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ndoff

Issue #188: during the 2026-09-23/24 datum setups the operator needed the
touch probe brought to the stock, slowed near the surface, stopped on
contact or at a measured datum and LEFT THERE for the touchscreen work-origin
confirmation; the existing tools did not compose into one approved,
sensor-gated approach that holds, and a continuous descent was proposed
instead. This is the stock-side counterpart of run_tool_setter
stay_at_trigger, built on the proven engine.

- approachHold.ts (pure): the plan (raise if below the traverse height, hop
  there to x/y, guarded descent to start_z_machine, sensor-gated march along
  dx/dy/dz - default -Z - bounded by max_travel_mm, the toolhead travel,
  machine Z 0 and an optional stated datum_z_machine), the confirm-page text
  that enumerates every move plus the hold and its release, judgeApproachEnd
  (contact IS the datum with on_contact "hold"; UNEXPECTED CONTACT with
  "abort" - never continued to the target; the stated datum reached released
  is the positioning success; the bare limit is an abort), planHoldRelease
  (trip -> nothing; operator moved the head -> Z-only raise, no back-off,
  held if still triggered; angled triggered hold -> back off along the
  reverse vector, verify release, raise; straight -Z -> the raise is the
  release) and the handoff text.
- probeApproachHold.ts: the runner. Refuses at staging without an armed
  probe channel; checks landmarks/keep-outs (law 4) and the stylus reach
  (#187); contact at the median is confirmed with the release / fine /
  confirm cycles and the HELD position is driven to and reported separately
  (hold_at contact | released). The hold is a real pause: the job stays
  started, job.hold / the "holding" event carry the held machine XYZ, the
  measured contact, the sensor state and the handoff; the loop watches the
  stop request, the trip latch, the sensor and the heartbeat (touchscreen
  jogs are reported as operatorMoved and drop the position of record).
  stop_gcode_job is the sanctioned raise: release along the vector, then
  straight up; a probe still triggered after the back-off is held, never
  dragged; the measurement and the hold leave on the stopped job's result.
- jobs.ts: McpJob.hold on the record and in describe(); stop_gcode_job
  description says it ends a hold with the raise.
- tests/approachHold.test.ts registered in run.ts (plan defaults and
  refusals, datum bounding, travel/bed clamps, page enumeration, end
  judgement incl. contact at the last increment, release planning, handoff).
- Skill text: cnc-motion-rules law 8 and a S4 sanctioned-exception entry;
  cnc-probing "Origin-setup handoff" section with the canonical calls, the
  op table row and planning.md; both zips rebuilt; TOOLS.md and README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stimate

T8c (job 96cf38d16b74, alpha.5) closed the QuadEink pocket lap but left
four residuals (#185). Every number below is from that job's result
(timing.byKind, trace.perimeter) or the T8b fixture.

1. Corner fits read low off a narrow slice. The greedy wall extension gave
   every wall-to-arc transition point (within the tolerance of the line AND
   the circle) to the wall, so the chuck -X circle was fitted to 61 of 90
   deg (r 4.06 for a ~4.4 corner). perimeterAnalysis now extends an arc's
   fit set over the ends of both adjacent walls by the split that minimises
   the two models' total squared residual - a point goes to the model that
   explains it better, judged over the whole transition - capped at the arc
   a tangent circle can subtend between the walls (180 - interior angle +
   the tolerance band's width). The corner reports arcSpanDeg,
   extendedPoints and lowConfidence (span under
   CORNER_ARC_SPAN_CONFIDENT_DEG = 70: the radius rides on a short sagitta
   and reads to a tenth, not 0.1 mm). On the real laps: T8b chuck +X (the
   corner with a verified centre) r 4.76 -> 4.63 vs the known 4.68, centre
   0.15 -> 0.11 mm off, span 74 deg; T8c chuck -X 4.06 -> 4.17 over 80 deg;
   the two exits that are real facets (0.13 -> 0.005 mm off the wall line,
   on the circle) move the other two fits 0.1-0.17 the other way; the
   spread of the four fits tightens from 0.70 to 0.46 mm and every span is
   on the record. The result note says "r over N deg [LOW CONFIDENCE]".

2. Two fixed 3.5 s waits: the confirm cycle's own march retreated one 0.1
   mm step from its coarse contact and waited the full release timeout
   while the switch's hysteresis held, then the next step released at once
   (2 of 9 cycles, 7.0 s). marchToContact's release retreat now waits the
   standoff window (standoffReleaseWindowFor, 500 ms at GPIO 50 ms) on its
   FIRST step - it lands on ground the march sensed free - and the full
   timeout on every later step and on the step whose answer is the fault
   (back at the start or at the retreat cap): marchCore.releaseRetreatPatience.

3. No net speed-up: the profile is 4250 commands at ~270 ms of transport +
   controller overhead each (1169 s of 1489), motion 172 s, sensor windows
   131 s - the runner has no redundant settle or sense left; every command
   is one sensed <= 0.1 mm move into unproven ground or a retreat to a
   proven point, and batching coarse steps would breach the 1 mm sensed-move
   law. What the step kinds do show: 315 of the 645 fine contacts are the
   6 mm straightness window walked at fine steps after the first wall and
   after every corner (50-61 each). straight_points already governs that
   window (3 halves it; the smallest radius that passes as straight falls
   from ~45 to ~11 mm, chord^2 / 8 tol); the tool description, confirm page
   and skill now say so instead of a code change the lap cannot verify.

4. The estimate said 5 confirm cycles and 7..61 min; T8c ran 9 in 24.8.
   expectedConfirmCycles follows the confirm_at cadence: the first wall,
   then per corner its first block ('unexpected') plus one per
   major_turn_deg turned inside it ('turns') - 2 per 90 deg corner at the
   defaults, 9 for four corners; 'turns' alone spreads over the lap's 360
   deg; the corners share that turn whatever the bounds' aspect ratio.
   TRACE_CYCLE_SECONDS 0.9 -> 1.5 (876 contacts: advance + ~1.7 bumps + ~2
   standoff retreats), TRACE_CONFIRM_SECONDS 10 -> 4, march 0.33 -> 1.0 s
   per mm, and the best case counts the straightness windows at fine steps:
   330 mm is 16..84 min. probeProgram's eventBudget uses the corrected
   worstCycles / confirmCycles unchanged.

Tests: perimeterAnalysis (T8b corner spans, extension bounds, a synthetic
R3 corner whose 60 deg slice is flagged), perimeterTrace (confirm-cycle
cadence, calibrated estimate, release retreat patience). Tool description,
README and the cnc-probing skill (+ zip) updated.

Closes #185

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Feature: Per-step move_z feeds, capture at a stated Z, bounded descent segments
The isolated probeInspection surface plan predates the stylus-reach check;
describeProbeSurfacePlanAsGcode now prints the verdict's note, so the fixture
carries the reach-unknown verdict the planner would have produced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stylus-reach

Feature: Add probe_approach_and_hold and refuse probes beyond the stylus reach
…er-followups

Fix: Perimeter tracer T8c follow-ups - corner spans, confirm lifts, estimate
jobs.ts now computes every job's program identity through ./gcodeIntake
(#237); the isolated jobModalRecovery fixture refused it as unstubbed
(4 failures on the startup/base run 39080339896). The module is pure, so
the fixture loads the real one, as jobDashboard.test already does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stub

Test: Supply gcodeIntake to the jobModalRecovery job fixture
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant