Skip to content

Sync main: packaging fixes (v0.1.1-v0.1.3) + README rewrite - #20

Open
richardosborne14 wants to merge 974 commits into
mainfrom
cline-dev
Open

Sync main: packaging fixes (v0.1.1-v0.1.3) + README rewrite#20
richardosborne14 wants to merge 974 commits into
mainfrom
cline-dev

Conversation

@richardosborne14

Copy link
Copy Markdown

Summary

  • Three packaging fixes that took v0.1.1's blank-launcher-screen crash to a working v0.1.3 (eslint-linter-browserify, globals, lodash — all the same webpack-externals/electron-builder-pruning gap across a workspace boundary).
  • README rewritten for NodeGX's actual current state (real Discord, live docs site, real backend model) instead of the pre-fork OpenNoodl/Noodl split.
  • Phase-33 session log entry.

v0.1.3 is already built, signed, notarized, and published as the release — this PR just brings the commits that produced it onto main.

🤖 Generated with Claude Code

richardosborne14 added a commit that referenced this pull request Aug 7, 2026
…s the sibling assertion

PR #20 CI (2026-08-07, seed 82518): "Project import and export unit tests
re-keys imported node ids while reusing the target component id" failed on
one of two parallel runs, same commit, different random seed -- confirmed
flaky, not a regression.

This exact test's own comments already document three prior rounds of
diagnosing identical order-dependent-identity flakiness through
NodeLibrary.instance (a singleton shared across the whole suite), each
resolved by asserting a stable property instead of raw object identity --
e.g. `instanceNode.type.name === '/comp1'` a few lines above this one,
explicitly captioned "asserted, unlike identity, because it is
order-independent". That caption turned out to be wrong for THIS assertion
too: `NodeLibrary.instance.typeCache.get('/comp1')` toBe(importedComp1) is
the last remaining raw-identity check in the spec, and seed 82518 is the
seed that proves it isn't order-independent either. Same fix as its
siblings: assert the name resolves to a real component, not that it's the
literal same object.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
richardosborne14 and others added 29 commits August 17, 2026 09:20
… never belonged to the client it was filed against

s49 built, gated, drove and committed FIX-023 (551a944). Fifteen tasks closed.

The headline is a correction to the task file: FIX-023 was scoped as an MCP-server defect,
but isComponentRef is shared noodl-editor code and the repo's own `npm run validate:project`
crashed identically. "The editor tolerates this node" was a fact about the editor's runtime
graph, not about its validator — two code paths in one process, disagreeing about one file.

Also recorded: the guard existed in two call sites and not one, with ~17 unguarded and seven
of those in the editor's own rules; and the corpus is 56 project directories, not 28, whose
legacy half reaches a second normalisation boundary the scoping never named.

test:ci is still not taken, but for a new reason — this is the first P66 change in four
sessions that the jasmine suite would actually have graded. Flagged rather than glossed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ved its blocker

Both were recorded as blocked on a viewer build. A build carrying the fix had
existed since 2026-08-16 21:57 — five minutes after the fix committed at 21:52
(2d8f8e0) — and three sessions inherited the blocker without checking either the
artefact's mtime or its contents.

- kit-app.editor-nodelibrary.json re-recorded from a running editor. Compared by
  NAME the capture is identical to its predecessor except for two values:
  demo.kit.Badge and demo.kit.Meter go module 'Unknown Module' -> 'Demo Kit'.
  Ports byte-identical, 177-name set unchanged. The ~400-line diff is pure
  reordering (kit entries now sort last); the JSON header says so, so the line
  count is not mistaken for a rewrite.
- kitAgreement.test.ts asserts ['Demo Kit', 'Demo Kit'] on the editor route. The
  describe block is renamed off "the one thing the two routes do not agree
  about", and the fossil comment now records why it was a fossil rather than
  restating the defect as current.

kitAgreement 9 passed; noodl-editor tests-unit/cn-003 22 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… that could not fail

CN-018 CLOSED and CN-007's D8 obligation driven, on the first free 9222 in three
sessions.

- CN-018 AC4/AC5: three kits draw three separately-named picker headings; the two
  Stat Tile cards are distinguishable without data-test. The two modes carry the
  kit name on DIFFERENT elements — browsing on the heading, searching on the
  card's second line — and reading only browse-mode cards nearly produced a false
  defect report. AC5 measured, not argued: kit and built-in headings are
  identical in tag, class and every computed typographic property.
- CN-007 D8: positive pills paint rgb(22,163,74) with no colour parameter set —
  not the old #1F8A4C, and not rgba(0,0,0,0), which is the exclusion that
  mattered. s14 removed the in-JSX || fallbacks on the reasoning that a declared
  default reaches props at initialize; had that been wrong, every pill, band and
  banner would have rendered blank. Both banner arms measured.
- Observation O3 RETIRED as unmeasurable. A planted unresolvable token computes
  to rgba(0,0,0,0), never literal text, so "0 var( in computed colours" is
  equally true of a perfect page and a wholly broken one. Its liveness control
  did not catch this: a control has to be a known-broken input fed to the
  instrument itself, not a sign of life taken from nearby.
- The mid-session pickup question, carried since s11, is answered: no watcher
  delivers a newly scaffolded kit (absent at every poll to t+30s); a viewer
  reload does. CN-014's spec hypothesis is false.
- CN-008 has a false premise: the scaffold emits no docs string at all, so
  "the docs string authors already write" is not written on the path this phase
  sends authors down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nothing

`AuthoringContextBuilder.nodeKitOverview()` and a `THIS PROJECT'S NODE KITS`
block in the cache-stable half, immediately after the catalog, wired at both
`AuthoringSession` call sites. Absent-means-omitted and charged, following
`libraryOverview` exactly. 17 tests, all seven mutations killed.

Two of the task's own clauses were false, measured before building:

1. "the AI does not know the lane exists" — it does. A kit node is
   `inNodePicker`, so `catalogOverview()` already named it and
   `nodeTypeDetails()` already rendered its full ports and defaults. What was
   missing is attribution and salience: it arrived buried in ~30 names on the
   `- Visual:` line with no kit named anywhere in the prompt. So the handout
   names ports rather than describing them.

2. "the ports an instance would actually set — the inputs with no default"
   selects ZERO ports on either kit in this phase, because an author defaults
   every port and D8 pushes them harder that way. Following it prints a node
   name with an empty list under it while `charge()` reports a cost and
   AC2/AC3/AC4 all pass. The discriminator is the runtime-added base set from
   `react-component-node.ts`, which takes `demo.kit.Badge` from 12 in / 9 out
   to the 8 and 1 its author declared. Not derived, because both derivations
   are wrong: intersecting the shipped catalog's visual nodes gives the empty
   set (`Component Children` has zero inputs), and intersecting the project's
   own kit nodes also swallows `radius`, which both authors meant.

`docs` turned out to be one field over two vocabularies: on a shipped node it
is a URL (158 of 175 carry one; 158 of 158 are docs.noodl.net, zero prose), on
a kit node it is prose. Fixed a real defect on the way past — `renderNodeType`
sourced its summary from `enrichedNode()`, which is keyed by type name and
generated at repo-build time, so a kit type can never be in it; the author's
`docs` was carried into the overlay by `@nodegx/kit-catalog` and then dropped,
and every kit node reached the model with no statement of what it is for.

Budget measured: 383 chars fixed + ~261/node; demo kit 934, cashflow-shaped
~2.2k, a 40-node kit capped at 30 gives 8,869 — ~7% of the 120k context budget,
in the cached half, and none of CN-009's 8,280 bar. Overflow is stated, never
silent.

AC1 is the consequence and is NOT met: it needs a live model.

Editor jest: 3555 passed / 229 suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last open FIX-016 build — the script-mode mining slice.

Two surfaces still mined `Inputs.`/`Outputs.` out of a Script node's text, and
the Script node mines nothing from its text: it is compiled
`Function('define','script','Node','Component', …)` and takes its ports from
`parser.getPorts()`. So the help bar told a correctly-written Script node to
"Read price with Inputs.price", and completion offered to INSERT that same
notation — the exact line s44's message 6 was underlining as throwing, in the
same popout. A completion arrives first and looks like knowledge.

The root cause was one predicate answering two questions. `modeHasDeclaredPorts`
is true for 'script' and correctly so: its scriptInputs/scriptOutputs proplists
are real. Consumers reached for it when they meant "is Inputs.x how ports are
written here". Adds `modeUsesPortNotation` (['function']) beside it and gates the
bar and both completion call sites on it; corrects the `unionPorts` module doc
that had named the wrong gate and so sent all three consumers wrong.

The tempting fix was one edit, not four: drop 'script' from
`modeHasDeclaredPorts`. That deletes message 6, which is gated on it. There is a
spec row and a mutant for exactly that.

The bar is silenced rather than fed an empty mined list — with nothing mined a
correct Script node falls to `unused-ports` and the bar nags the author who did
the right thing. A bar that teaches `define()` is deliberately not built: picking
a row needs the node's real port list, which means running the author's code.

Censused first, and the scoping was wrong in both directions: FUN-005's rail does
not exist (four doc comments, no component), FUN-004's diagnostics was already
fixed by s44, and FUN-008's completions — two call sites — were not mentioned.

Gates: noodl-core-ui jest 27 suites / 498 tests; root typecheck exit 0;
typecheck:core-ui 44 errors, unchanged, none on the changed path; lint:ci exit 0.
Spec: 21 tests, every behavioural row paired across the two modes. Three mutants:
bar gate removed 4 failed, completion gates reverted 5 failed, the "tidy" 2
failed including "message 6 still fires".

Driven 2 arms x 6 rows in a live editor on a copy of fix016-msg6-drive, whose
PriceDiscount carries both node types. The bar: 0 PortHintText on the Script node
across four documents, 1 on the Function node. Completions: 0 with no tooltip on
the Script node, `Inputs.price` with a rendered tooltip on the Function node.
`define` still completes in script mode with its info text — the row that shows
the suppression is of the notation, not of the feature. Message 6 still fires.

Two existing CN-019 rows in portBar.test.ts asserted the old behaviour and are
changed, not deleted: they used 'script' as a stand-in for "a mode with declared
ports".

The drive's own correction: the first completion probes used a bare prefix at
position 0, which is an isDeclarationPosition where ports are correctly refused
in BOTH modes. The Function control came back empty too, which is how it was
caught; re-run at an expression position the pair separates. A one-armed probe
would have recorded a pass that was true and proved nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… scoping wrong in both directions

s50 closed FIX-016's last open build. The census found FUN-005's rail does not
exist, FUN-004's diagnostics was already fixed by s44, and FUN-008's completions
— two call sites, the half that mattered — were never named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd a query that found nothing

AC1–AC4 met, AC5's consequence needs a live model. `src/catalog.ts` only; 14 tests,
8/8 mutations killed, noodl-mcp jest 599 passed / 51 suites.

AC3 first, because the task says to measure before designing:

  before:  [surface] 8223 tokens / 20 resident tools — 57 under the 8280 budget
  after:   [surface] 8223 tokens / 20 resident tools — 57 under the 8280 budget

Unchanged because this spends nothing: no description, schema or group purpose
was touched — every fact added travels in a response, which the gate does not
measure. So there is no third renegotiation and the $ref move stays available.

Two criteria were already met, confirmed against the real cashflow project rather
than inherited: all five nodegx.cashflow.* types listed, and Pill returning
23 in / 14 out exactly. Provenance was the missing half — both projections
dropped providedBy/kitModule, so an agent could not tell an author's node from a
shipped one.

The finding is bigger than the task. `summary` was read from `enrichment`, which
is generated at repo-build time and keyed by type name — a kit type can never be
in it — so the author's `docs` sentence was carried faithfully into the overlay
by @nodegx/kit-catalog and then dropped. A kit node reached the model as a name,
a category and a port list, with nothing saying what it does.

It also cost the kit its only free-text handle. Measured on the cashflow kit:

  query "draggable"            []  ->  nodegx.cashflow.Pill
  query "snaps to whole days"  []  ->  nodegx.cashflow.Pill
  query "pill"                 Pill -> Pill   (matched the NAME)

That third row is why nobody noticed: every query you would try while already
knowing the node existed worked. Only discovery failed.

This is CN-008 finding 3 in the other consumer — two bugs, not one reachable
twice, since the editor reads the repo-build enrichedNode() table and this reads
the merged catalog document. Each side's comment now points at the other.

The gate on that fallback is load-bearing and its obvious test cannot fail:
`docs` is prose on a kit node and a URL on a shipped one (158/175, all
docs.noodl.net), but all 175 built-ins also have an enrichment.summary, so the
fallback is never reached for them gated or not. The control is therefore a
hand-built node shaped so it would fire, and it dies when the gate is removed.
Found by mutating: an empty `docs` string survived the first version, because
getNodeTypeDetail guards with `if (summary)` while listNodeTypes assigns it
unconditionally — so the row, not the detail, is where it breaks.

One scope reading, stated rather than taken quietly. "find_tools matches kit node
names" is a no-op as written: list_node_types and get_node_type are in `core` and
are resident, so there is no deferred tool to reveal. Built on
list_node_types({query}) instead — zero tokens, and kitTools.test.ts' deliberate
find_tools control is untouched and green.

AC5's caller is built and driven: a real session searches by subject, reads
provenance off the row, fetches ports, places the node and validates clean, with
a control that mistypes the port and gets `unknown-parameter … did you mean
value?` — the diagnostic, not just a refusal. But the choice to search for
"percentage" is mine, not a model's, so the consequence half is NOT met.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…u already knew the answer

s17 built CN-009. AC1-AC4 met for zero tokens; AC5 needs a live model, and it is
the same live-model drive CN-008 AC1 needs — one drive closes both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… azure

Added out of Richard's read of the running site — "a bit simple and sad right
now … I don't think it fits our sleek new NodeGX style in the editor."

## The diagnosis is not the complaint

It is not a weak design. There is NO design system. globals.css declares SEVEN
custom properties and all fourteen pages are built on them, while the editor
ships a canonical, documented two-tier system whose own header calls itself "the
only definition of colour tokens". Measured 08-17 from the three stylesheets: 7
tokens against the landing page's 24; 2 surface steps against the editor's 5; no
mono face, no display face, no light-theme convention.

🔴 The detail that diagnoses it: the site's accent is #4b9fff and the editor's is
#4da3ff. Four hex digits apart — close enough that someone was aiming at the
editor, far enough that they were doing it by eye. Nothing was imported, so
nothing stayed in sync. That makes "paste better values in" the one fix
guaranteed to fail the same way, which is why the drift test is the deliverable
rather than the paint.

## D18 — ruled azure, AGAINST the recommendation

The pitch argued teal, reasoning that the landing page is what a stranger meets
first and the community site sits directly behind it. Richard ruled azure: one
hue across the two PRODUCTS, which is the pair a user moves between and the pair
UNI-011 puts inside one window.

Recorded with its consequence rather than as a bare verdict — the landing page
becomes the odd one out, chosen and not inherited, so D18 is now the reason
nodegx-web moves rather than the community site if anyone reopens it.

⚠️ This register carries a standing note that "a recommendation adopted wholesale
is weaker evidence of a good decision than one argued down". D18 is the first
ruling in this phase that was argued down, and the file says so.

## The engineering question the task actually turns on

Two repos, one token set, no build step between them and no registry. Copy (what
produced #4b9fff), publish a package (real infrastructure), or vendor with a sync
script and a DRIFT TEST. Chose the third: a vendored copy with no check is the
first option wearing a hat; what makes it honest is that divergence becomes a
failing test.

⚠️ And it inherits a known hole from the pattern it borrows: the platform's schema
drift spec compares tables and columns AND NOT enum names, so a half-done rename
passed it. The token test must compare every declared property and its value —
two files with the same number of different colours pass a count.

## Also recorded, and it is not cosmetic

Deployment is owned by no task. Measured 08-17: nodegx-community has no
Dockerfile, no CI workflow, no ops/, no fly.toml, and a grep across all twelve
task files for deploying the platform returns zero hits. UNI-008 is about hosting
users' apps, not this. Same shape as slice 1's finding — D14 called the API a
deliverable, no task owned it, so it did not exist.

The next-session prompt leads with an unrelated launcher tweak Richard asked to
have done first, and flags that it is not phase 67 work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… 'learning'

Richard, using the app: "you should see your projects first when you open the
launcher, not the learning bit which takes up the whole top of it."

D5's Learning section moves out of the Projects view and into its own header
tab. The section itself is unchanged — D5 chose the visible option because
visible progress motivates, and a permanent tab is visible; what it does not
survive is being the launcher's opening screen.

The tab affordance already existed. HEADER_TABS, the accent underline, the
persisted active tab and the deep-link parser all shipped with PAR-001, so this
is one table entry plus a view, not phase 37's project tabs arriving early.

The trap is the naming. There were already two things called learning:

  'learn'    POL-002's retired catalogue of hosted lessons, whose content
             predates every project-format change since LEARN-001. Kept
             compiled, reachable from nothing.
  'learning' UNI-007 / D5's installed lessons. The new tab.

Reusing 'learn' would have put the dead catalogue behind the live tab. They are
one letter apart, and usePersistentTab's isValidPageId is the single place a
*stored* string decides which page you land on — so the spec asserts both
directions, accepts 'learning' and still rejects 'learn'. A spec checking only
the first would pass just as happily with both accepted; the control run proves
it does not (1 of 3 red with 'learn' accepted).

Driven, and the consequence checked rather than the mechanism: "Recent
projects" is the first heading at y=81 with cards on screen, the learning
section selector is absent on Projects while firing on the Learning tab, the
tab persists across a reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r places

Discharges FIX-004's redaction ruling (b), s42: a `noodl_log` block could print a
provisioned secret to stdout in the clear, where the `Log` node could not.

The ruling read as "make the generator emit something other than console.log".
Rejected on population: `generatedCode` is a string saved in every existing
project and every one already says `console.log`, so a new emission would fix
programs saved from tomorrow and leave the ones on disk leaking. Instead the
compiled function gets an 11th parameter named `console`, which shadows the
global inside a `new Function` body — both populations, one edit, and the
editor↔runtime contract untouched (the three specs pinning `console.log(…)`
stay true rather than being rewritten).

No sink means the REAL global console, by identity, not a forwarding shim — so
the browser is the same call it always was, objects included.

The parameter list turned out to have FOUR spellings. The census found one; the
other three were found by `vfn-011/drift-gate.spec.ts` failing on arity, because
it reads `compiled.length` off the runtime's own compile rather than off its
source. The bench is a third executor of block programs and would have gone on
running them against a ten-parameter contract. That gate had never fired before.

Measured through a real BackendService over real HTTP: the row that recorded
"a block-logged secret reaches stdout in the clear: true" now records `false`,
with `toContain(REDACTED)` beside it so the absence has a firing signal — the
secret being absent also passes when nothing was printed at all.

The suite's own stated premise had to be repaired: "a console.log from generated
code does not go through the logger" was true when written and false after this.
Left alone, all four presence assertions failed for a reason unrelated to the
block. Note the env var beats the configure option (ops/logger.ts:78), so
NODEGX_LOG_LEVEL is deleted before the level is set.

Four mutants, each red, each reporting a real test count: the parameter dropped
(leak returns, by name, while the derived-value control still passes); `console`
inserted before `__p` (the positional near-miss); the no-sink shim; and String()
instead of JSON folding.

⚠️ A block's console.log is now subject to the service log level, where before it
always printed. Consistent with the Log node and the ruling; production default
is info.

Gates: noodl-runtime 137/2510, nodegx-backend 100/1085, test:main 229/230 (the
one failure is the bld-004 load flake, 8/8 alone), viewer-react 71/910,
cloud-runtime 7/172, typecheck exit 0, lint:ci 876 vs 3916. test:ci NOT taken:
peers hold uncommitted noodl-core-ui edits, one saved 35s before the run would
have started, and noodl-core-ui is bundled into the renderer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a list that had four

FIX-004's redaction (b) is closed, so FIX-004 has no open build left. Seventeen
tasks now closed outright, one of them a peer's FIX-024 landed mid-session.

Records three things for the next session. That the census read _compileFunction,
found 'the' parameter list and was wrong by three — the other spellings live in
evaluateFragment, in the editor's BenchRunner, and in the drift gate's own
literal, and a gate in a package the change never touched is what said so. That
the ruling named a mechanism which was the wrong half of the problem: changing
the generator would have fixed programs saved from tomorrow and left every
generatedCode string already on disk leaking. And that a suite's stated premise
expired, so four presence assertions failed for a reason unrelated to the
feature.

Also marks the seven gates taken, the two exit codes read wrong before being read
right, and why test:ci was declined — the process table said clear and the tree
did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… half is a session's

Rewrites the next-session prompt around a measurement rather than a lane list: of
what is left in the phase, three items are buildable by a session and seven are
blocked on a purchase, an account, an artwork or a decision. Six of the seven
trace to four asks, and a forum gates three of them on its own.

Re-measured rather than inherited: community.nodegx.io still resolves to
nexus-1, has_pages is still false, the sibling checkout is clean at 7193f92.

Two corrections to the last handover, both in the direction that misleads. It
listed the deployment absences accurately and omitted that docker-compose.yml
exists — it is dev/test Postgres only, and a reader checking for a compose file
finds one and distrusts the whole list. And the platform does carry a lint
script while carrying no lint config, so a reader who checks package.json
concludes the handover was wrong about the gate.

Records the launcher tweak (FIX-024, 7b7a778) as phase 66 work, and the
unowned hole it exposed: noodl-core-ui cannot be eslinted at all, so "eslint
clean on every touched file" was never true of a core-ui file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…skip

CN-010's AC2 and AC3. The spec's premise for AC3 was false three ways, and the
third was the defect: `get_node_type` read `dynamicPorts.note`, a field that
exists on no node in the product. The catalog's field is `description`, and it is
non-optional on both `DynamicPortInfo` (88 shipped types) and
`OverlayDynamicPortInfo` (every kit node) — so every dynamic type answered "this
port list is incomplete" with no statement of how. It typechecked because the MCP
server declared its own local shape for a document it does not own. The editor's
`CatalogIndex.dynamicPortNote()` reads `description` and was right all along.

That is the third such field in three consecutive tasks, after CN-008's `docs`
and CN-009's `summary`, and the common cause is not that kits were forgotten: a
second consumer re-declares the shape it reads and nothing types the seam.

AC3 (`noodl-mcp/src/catalog.ts`):
  - `description` reaches full detail, for shipped types and kits alike
  - `declaredPortGroups` too — 34 types, 165 groups, 158 conditions — projected
    to {condition, inputs, outputs}, exporter internals dropped
  - a kit's `hasDynamicPorts` flag finally carries a sentence; it never could,
    since `runtimeBehavior` comes from enrichment, keyed by type name at
    repo-build time (88/88 built-ins have one, 0 kits can, ever)

AC2 (`noodl-editor/.../validation/parameterValues.ts`): the runtime-dynamic
carve-out was a bare `continue` — 947 set parameters across 321 nodes of the 29
real test projects, 6.0% of every parameter in them, reported as a clean pass.
Now an `info` per node, reusing the `DynamicPortSkipped` code `nonexistentPort`
already emitted for connections; one of two sibling call sites had the notice.
Emitted unconditionally rather than behind `emitDynamicPortInfo`, because no
production caller can set that flag — justified by measurement instead (median 0,
p90 2, max 17 per component).

Four silence baselines were replaced rather than deleted, per CN-002's rule; each
now asserts the guarantee (not accused) instead of the shape (empty array).

The full-detail response ratchet moves 12,500 -> 13,500 with its arithmetic
recorded: 283,483 -> 294,894 tokens across 142 types (+4.0%), worst single
12,069 -> 12,899, headroom 431 -> 601. The ceiling case is no longer `Group`.
The cheaper-looking `activeWhen`-per-port encoding was measured and is not
cheaper (29,014 vs 22,911 bytes).

23 tests, 15/15 real mutations killed. noodl-mcp jest 613/52 suites, editor jest
3,567/231 suites, resident surface 8,223/8,280 unchanged, tsc 8 pre-existing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t had stopped existing

Session 42's ruling, built and measured. `NODES_BEFORE_CODE` is a fourth prompt block carried by
both channels: weigh the library heavier where one node does the whole step, and — the half that
stops the first one making graphs worse — once a calculation needs code at all, do all of it in
that one code node. The forbidden shape is named in the concrete.

Separate from THREE_WAYS_TO_COMPUTE on purpose, in both directions. Session 39's control removed
the two older blocks together, so it could say the pair moved `Substring` from 10/10 to 3/10 and
nothing about which block did it. The harness gets a second arm, --node-weighting=off, which
subtracts this block alone.

n=10 per arm, two requests, arms interleaved, 40 sessions all valid on first submit:

  fix006-string-math (simple)   Substring 8/10 ON vs 0/10 OFF; 8x `Substring -> Expression`
  fix006-price-line  (complex)  a single JavaScriptFunction 10/10 in BOTH arms
  alternation (>=2 class crossings)   0/40

The second table does not show the exception clause preventing alternation — the hazard never
appeared in the control either, so it is unfalsified rather than proven. It shows the thing the
ruling warned about: a rule that only pushed "use the node" would manufacture that chain, and this
one did not.

Two instrument repairs, both of the same shape:

- The control arm's marker `Reach for the Script node LAST` appears nowhere in the product. Session
  43's AC4 edit reworded that line and did not touch the harness; because the check only asked
  whether a marker SURVIVED the strip, a marker that had stopped existing passed silently. Markers
  are now asserted present before the strip as well as absent after — which caught this session's
  own first choice of marker, a phrase that falls across a line break.
- The grader scored an `Expression` as a JavaScript body, so the arm that reached for a node read
  as 1/10 "const/let" against 4/10. An Expression holds one expression and can contain neither
  `const` nor `var`. Style is graded over statement bodies now; the rate is 1/1 and 4/4.

And the copy is graded at last. Session 43 recorded that nothing anywhere asserted this block;
`tests-unit/fix-006/promptGuidance.test.ts` is 11 specs in the plain-Node runner, checking every
node name against the shipped catalog rather than a second hand-kept list, plus four more
assertions over real MCP stdio.

Gates: typecheck 0 · test:main 232/3578 · noodl-mcp 52/613 · lint:ci 876 of a 3916 baseline.
Measurement cost $0.89; both arms archived so the A/B can be re-read without re-spending it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hecks that could only pass

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ight and failed AA

Records session 24 in TASKS.md, the UNI-013 task file and the handover.

The slice landed in the platform repo (`f64f138`). What is worth carrying back here is
what AC2 cost: the obvious token by name, `--theme-color-fg-muted`, is an editor CHROME
label colour and scores 3.19:1 on the light page ground — below AA, on the lede of every
page. Two screenshots of the two themes said the site was fine. A criterion that is a
number caught what a criterion that is a look could not.

Three more, all filed where the next person meets them:

- A control pair needs its arms PINNED, not merely required to differ. Every contrast
  ratio passes in dark, so a light arm that silently returned the dark palette would have
  gone green while measuring one theme twice.
- A Map-based diff collapses a duplicate key silently, and the probe that found it did so
  by landing on an occupied name — which measures the collision, not the mutation.
- `pkill -f "next start"` matches nothing (Next renames to `next-server`), so a stale
  server served a CSS hash that no longer existed and the page rendered completely
  unstyled while build, sweep and 516 specs all stayed green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…frozen

First live-editor measurement of CN-010 AC1, with the observations written down
before the editor was launched.

The conditional-group form works end to end and nothing on the path is
kit-aware: `inputs: ['itemCount']` arrives in the editor's own format with the
condition intact, the row appears at `mode = list` and vanishes at `mode = grid`
reversibly, it is connectable through the real connection popup, and the wire is
drawn, saved and live at runtime.

The `channelPort` form is erased in every surface and every state, including
`getPorts()` with the runtime live and the node mounted. A census over the live
library found `channelPort` in exactly one of 177 types — the fixture's own kit
node — which is why nothing ever went red: the exporter strips the port
expecting an editor-side manager, and that manager is commented out.

The finding that outlives the criterion: a kit node's definition is delivered
once and then frozen. One write flipped a condition and added a new node; one
reload delivered the new node and never the changed condition, and a full editor
reload delivered both. Same file, same write, same reload — so the only variable
is whether the editor already knew the name. The cause is a TODO on the line, in
the function s12 flagged as wanting a look.

Two instruments nearly produced false findings and both are recorded:
`setParameter` does not re-render the property panel, and `WarningsModel`
returns 0 beside a deliberately bogus node type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… like the defect

Session 43 named the missing control: every FIX-022 cell measured
over-decomposition where reuse is IMPOSSIBLE, so nothing would notice a
"don't factor for a single use" rule damaging the case Richard builds on
purpose. This is that cell.

- `reuse-available`: the one corpus request where creating a component is
  correct — the same badge wanted at three existing sites.
- `expect.minPlacementSites`: the reuse half of the oracle, set only where
  reuse is actually available.
- `plan-grade.ts`: the grader, extracted so a spec can hold it (the harness
  calls main() at import). Placement counts on `instantiates` OR on the
  intent naming it, and reports the split — the structured field was filled
  on only 5 of s41's 11 real placements, so a structural-only grade would
  have scored 6 real placements as never placed.
- 15 specs in test:main; three mutants each kill named specs.

Measured n=10 per arm, interleaved: all 17 planned sessions factored one
shared component and placed it at all three sites. 0 single-use. The guard
has a clean baseline and the doctrine moves nothing here either.

Three sessions died on an exhausted API credit balance. A session that never
planned grades as 0 creates and fails the oracle — indistinguishable from
"the planner refused to factor". summarise() now grades planned sessions
only and prints the provider's note; verified against a real failing run.

✅ The mechanical grade reproduces s43's hand re-grade exactly: 11 creates,
11 single-use, 0 reused, same runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ths that graded as findings

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tate nothing could express

FIX-013 ruling 1(c) says the shim should serve zero rows. Both earlier readings
of the task reached for `useSampleData: false` to get there. That flag
uninstalls the network shim and points the preview at the project's real
backend -- it is AC3's violation, not its implementation.

The other half is worse: an empty dataset is not an empty sandbox.
`SandboxStore.list()` invents five records for any class it has never heard of,
deliberately, so a preview never strands a graph. Shipping `{ classes: {} }`
therefore serves five synthesized rows per class queried, not zero. The
behaviour the ruling rules against was already pinned by a spec, which is how
it was found.

So the third state is built here, and switched on nowhere:

- `SandboxDataset.synthesizeMissing`, defaulting to true, so `undefined`,
  `true` and no-dataset-at-all are all the behaviour that shipped before it.
- `SandboxStore.list()` honours it and still caches, because a form with no
  rows behind it has to stay writable.
- `buildSandboxDataset({ emptyState: true })` ships the class list named and
  empty -- a named class is what stops the store inventing it -- drops the
  unknown-shape caveat, and says the emptiness is the point.

`buildBenchExport` is untouched on purpose: flipping the bench without removing
the toolbar in the same commit would offer an empty Data panel on every
component instead of one, which is the reported screenshot spread wider.

Gates: typecheck 0 errors; noodl-runtime 137 suites / 2515; test:ci 2849 specs
/ 6 failures, the same six by name; lint:ci exit 0 at 877, measured identical
with the change reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thing could express

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records `d205b47` in TASKS.md, the task file and the handover. Lane A is closed —
only slice 4 (the badge artworks) remains, and it is Richard's.

The sequencing lesson is filed where the next person will meet it, because it is the
kind that repeats: slice 1 was scoped as "most of the win" and is nearly invisible,
and it was landed first against a complaint that was explicitly about appearance.
Richard's reaction on seeing it was "I thought the style was redone??". The ordering
was defensible on engineering grounds and wrong on the grounds that mattered. When an
ask is about how something looks, ship something that looks different in the same
session.

Two more defects AC2's arithmetic caught that rendering could not, both now in the
handover's findings list:

- A theme-INVARIANT ground given theme-DEPENDENT ink passes in one theme and fails in
  the other. `--theme-color-on-primary` flips; `--theme-color-avatar-gradient` does
  not, so the avatar was white-on-azure at 2.63:1 in light only.
- `bg-page` is DARKER than `bg-0` in the light theme — it is the page's floor, not a
  chrome surface, and a sticky header on it cost 0.03 of the AA bar.

And one that needs no contrast maths: a token can name an asset that is not served.
`--font-family-display` resolved to its system fallback on the web for as long as the
site existed. Correct-looking source, wrong render, no error anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eaten 84 built-ins

s19 found that a kit node's definition is frozen after first delivery: one write
that both changed a node and added one delivered only the addition, and a restart
was the only recovery. That finding is CN-014 AC1 verbatim -- not a new task, and
two sessions had already done CN-014's mandatory measure-first pass without the
task being open.

Two causes, both in NodeLibraryImporter:
  - mergeUpdates' known-name branch carried `// TODO: Update the node data?` and
    discarded the new definition;
  - mergeInByName DID replace the picker-index entry but never set `updated`, so
    updateIndex published nothing and NodeLibrary.reload() never ran -- which is
    why adding a node to a kit refreshed its group and editing one did not.

The obvious fix corrupts 84 built-ins. The generated cloud library merges once per
session, shares ALL 84 of its type names with the browser library, and all 84
definitions differ -- so an unconditional replace hands Expression, REST2, Model2
and 81 others to the cloud library and inverts a first-writer-wins precedence.

Three candidate keys, measured before writing anything:
  - `runtimeTypes` contains the runtime -> it is a UNION; cloud captures the 84;
  - `runtimeTypes.length === 1` -> the mirror failure, stops refreshing those 84;
  - `clientId` -> a viewer mints a fresh guid() per socket open, so it never fires.
Hence a separate dataOwner map: whose report the data came from, which is a
different question from where the node can run.

8 tests, 5/5 mutations killed. The precedence control is built from the two real
payloads on disk and asserts they DISAGREE before asserting the browser's survived
-- overlap alone would have made it vacuous.

Driven: one write, one viewer reload took the library 177 -> 178 AND
Title -> Panel Heading, the combination that was impossible before.
Residual, reproduced twice: an open property panel does not re-render on
libraryUpdated (six polls over 30s untouched, then updated on one re-selection),
so the variable is the selection change and not elapsed time. Recovers by clicking
any other node and back. AC1's second clause is recorded UNMEASURED, not working.

editor test:main 3,601 / 234 suites; typecheck:editor 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UNI-013 was scoped from a "Seven Tokens" artifact — a NOW/PROPOSED profile card in
full CSS. I built slices 2 and 3 from the task file`s prose and never opened it. The
prose carries one of the five moves (the mono face); it does not carry the dot grid,
the tier colour on the badge`s edge rather than a decorative disc, the headline points
figure and meter, or the eyebrow/wire/mono-label hierarchy — and those four are what
make it look like the product.

A written summary of a visual decision loses the visual decisions, and it reads
complete because every sentence in it is true. The artifact URL is now at the top of
the task file and in the handover, with the two caveats that matter: it is drawn in
teal and says so (D18 ruled azure — take the forms, not the hue), and it is a mock, so
its "Coach · Acme" eyebrow names an org PublicProfile does not carry.

Also recorded: a sweep is only as wide as the surface it enumerates. The AC1 sweep
quantifies over stylesheets, so the site`s first inline style was outside it entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eady hiding

FIX-013's UI slice: the bench serves no rows, and the toolbar that would have
advertised an empty Data panel goes in the same commit — flipping one without
the other would have spread the reported defect to every benched component
rather than fixing it on one.

buildBenchExport passes emptyState: true, hard-coded rather than offered. A
bench that can be switched back needs a control to switch it with, and the
control IS the reported defect. SandboxToolbar and SandboxDataEditor lose their
bench mounts along with the four pieces of state they owned; useSandboxViewer is
now called with literal trues, which is AC3 structurally — it is the only writer
of noodl-sandbox-data, the runtime disables the shim for the literal string
"real" alone, and nothing the bench renders can now produce it.

The summary did NOT go where the Fix direction said. It called for the chrome
strip beside BenchCaption; that placement satisfies the letter of the task and
defeats AC2. describe() appends the backwards-ports sentence LAST, and
BenchCaption is a single nowrap line whose own comment names it the shrink zone
because BEN-004 measured the strip clipping at 640px. A sentence appended last,
dropped into the element designated to truncate first, is not relocated — it is
hidden. So it lives in the bench, full width and wrapping, with no height cap.
That is strictly better than the toolbar it replaced: a 30px nowrap row with an
ellipsis, which was already clipping the diagnostic. AC2 is met here for the
first time, not preserved.

Ruling 2 is answered as the two surfaces diverge, recorded in the doc as the
ruling required: the toolbar, the data editor and sandboxDataDraft all keep
their owner in SandboxPreview, so nothing is orphaned and nothing left disk.
Rulings 3 and 4 take their non-destructive branch — signedIn and useSampleData
survive as programmatic options with no UI, so the Real-backend assertions
needed no edit at all.

The first spec written for this failed, and the failure was the finding:
ShareItem reads no collections, so the class map is EMPTY — the identical shape
to the bug report's CategoryCard. That makes synthesizeMissing: false the only
thing doing any work on this component, since with classes {} and synthesis left
on the bench would serve five invented rows per class queried, more fabricated
data than before the change meant to remove it. It also caught a spec that could
not fail: a loop over Object.values(classes) asserting records.length === 0
iterated zero times and passed without measuring anything. Both were rewritten —
the empty map asserted directly, the populated case pinned by supplying a class
rather than hoping the fixture had one.

⚠️ Built and gated, NOT yet driven: AC1 and AC2 want a running editor and a peer
holds the checkout. Fixture is ready at NodeGX test projects/fix013-drive, whose
/Probe carries a pBackwards port plugged "input" for AC2.

Gates, taken this session on a tree measured clean of a peer's seven uncommitted
paths (test bundle 23:41:30, earliest peer edit 23:42:39):
  root typecheck   exit 0, zero error TS
  test:ci @ 39393  2849 specs / 6 failures — the floor, the same six by name

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and a spec that could not fail

s55 built FIX-013's UI slice and did not drive it; AC1/AC2 are the whole of the
open work and the fixture is already waiting at fix013-drive.

Records the two things a builder would otherwise repeat. The Fix direction's
chrome-strip placement would have hidden the diagnostic it was meant to preserve
— describe() appends the backwards-ports sentence last, and BenchCaption is the
strip's designated shrink zone because BEN-004 measured it clipping at 640px. And
a new spec passed by looping over an empty collection zero times, which is why
the spec that FAILED was the one carrying the finding.

Also corrects a claim s55 nearly shipped: tests/ IS typechecked, by
typecheck:editor-tests. The bare typecheck covers src/ only, and there are nine
typecheck:* scripts that it is not the union of. Gate taken, exit 0.

lint:ci re-measured rather than inherited: 877, unmoved by the deletion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CN-006b AC1-AC4, built and driven. The list, removal and the property-panel
provenance row; item 2 (the create entry point) turned out to be already built
by CN-006, which reading the file first is what established.

Three of the spec's own premises were false:

- "the right node count and VERSION" — no manifest in any of the 29 real
  projects has a version field, the scaffold writes none, and the manifest
  schema has no version property. The row reads one when present and omits it
  rather than defaulting; CN-016 owns what a kit version means.
- "with a link to its docs — the field already exists" — it exists and it is
  PROSE. docs is one field over two vocabularies (CN-008's finding, third
  consecutive task): on the payload a real viewer sent, both kit types carry the
  author's sentence and none of the 175 built-ins carries the field at all.
  An href would have opened nothing.
- provenance "already carried" — module was already on the exported type and
  needed a reader, not plumbing.

A P1 capability gap closed in passing: getNodeDocs reads the build-time enriched
catalog, keyed by type name, so a kit type can never be in it — the header's help
button was gated on that lookup and a kit node had 2 action buttons to a
built-in's 3. Driven, and the old gap reproduced live to prove the fix caused the
equality rather than merely coinciding with it.

There is no kind:'node-kit' marker; the rule is subtractive and was built from a
census of all four manifest shapes on disk, because a fixture with only a kit in
it passes against a list that returns every module it finds.

29 tests, 15/15 mutations killed (two of which exposed tests that could not fail,
not code that was wrong). test:main 3,630/236 re-measured; typecheck:editor 0.
CN-006b is built and driven; the tier-2 authoring arc is complete. The handover
leads with the part that generalises: three of the task's own acceptance criteria
rested on premises that a census contradicted, and two of them would have shipped
a wrong surface (a version field no manifest has, and an href over prose).

Also records the four CDP traps the drive turned up — the native confirm modal
that takes CDP down with it, hover not opening the Tooltip, the fiber walk that
reports the last element in the container for every element, and cdp click taking
the first of ~400 matches, which opened a peer's project before it was caught.
richardosborne14 and others added 30 commits August 21, 2026 10:56
…d gate

Bug 6 closed, FIX-028 recorded, commit hashes corrected, nine FIX-027 reports
rather than seven.

🔴 The one that matters: the test:ci floor in the appendix was measured on
3d8b162, BEFORE FIX-028 — and FIX-028 edited
packages/noodl-editor/tests/canvas/CanvasThemeNodeSchemes.test.ts. `tests/` is
the electron suite that only test:ci runs; `jest --listTests` does not list it,
so test:main never saw the change. The edit looks safe (a token fixture whose
assertions are about node-category colours, which did not move) but that is a
reading and not a measurement, and the prompt now says so rather than quoting a
floor that no longer applies to the tree.

Also recorded from FIX-028: palette-copies.spec.ts caught 15 stale copies across
5 files, so it is the thing to run after any palette change; and only tokens
moved, so hard-coded greys in the editor's legacy style.css did not follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nvisible

Bug 21's editor half. `useLearnerPath` surfaced only `refused` and dropped
`absent`, `unauthenticated` and `unreachable` on the floor, which is why Richard
saw a console 404 and nothing on screen. The field mismatch behind that 404 is
fixed on the platform — but a client that says nothing when a write fails would
have hidden the next cause just as completely.

`projectionFailure` now words all four, distinctly. Two corrections fall out
that were not in the report:

The hook's comment claimed a refusal is "worded by the platform and shown
as-is". It was not — it was routed into `{ kind: 'failed' }`, whose note is a
fixed "could not be written, and it will not be retried". Wrong twice for a 409
the learner can fix and retry themselves.

And `absent` is narrated here, deliberately. D15's silence protects a surface
the viewer must not learn exists; this one is already on their screen with a
button on it, so silence is not privacy, it is a dead control. It is worded as
a fact about the step, never as an error.

UNI-001 AC4 caught the first placement and the boundary was right. The helper
went into `learnerpathview.ts` first because that module is tested, and AC4
asserts that the module deciding what is DRAWN never sees a session — which the
`unauthenticated` sentence trips. The answer was the boundary, not a reword:
`projectionNote` maps the five outcome kinds inside a 200 and is a drawing
decision; this maps the transport outcomes of a write and knows about
credentials. It stays in the hook, exported for the spec.

Graded by mutation twice — collapsed to one sentence, and reverted to the
pre-fix behaviour — 4 of 6 arms go red each time. The two survivors are the two
that should. typecheck:editor, typecheck:editor-tests, eslint all exit 0;
test:main 300 suites / 4871 tests / 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…harness

The release's one open engineering question, and the answer was one line below
where every previous session stopped reading the log:

  Test run timed out after 900s without reporting results.

The cap is not the job's — that really does set no timeout-minutes, which is
what made this invisible. It is DEFAULT_TIMEOUT_MS in packages/noodl-editor/
test.js: 15 minutes, overridable by NOODL_TEST_TIMEOUT_MINUTES. The suite needs
about 15 minutes. Runner variance alone decided whether a run graded anything —
one finished of four, and the three that did not were killed at 2766, 2264 and
2194 specs with no summary line, which reads as a failing gate rather than an
unfinished one.

Three earlier readings were wrong and all three fit the evidence. "Degradation,
not a wall-clock cap" is backwards. "Both incomplete runs died in AIX-011, that
is the death zone" — this one died in AAQ-011; there is no death zone, a run
dies wherever 900s falls. And the listener-flood comparison was confounded: the
completed run carried more warnings because it ran more specs, and per-spec the
rates are indistinguishable, so that count could never have decided it.

Measured over all 2264 spec starts: the per-spec median is 0.001s and flat from
first decile to last, so nothing degrades. 59 specs take 17-20s each and eat
12.4 of the 14.9 minutes, all authoring specs (AAQ-*, AIX-*, AIB-004, BEN-001).
Making those faster is the real work; this only stops the cap deciding.

Raised to 30 minutes with the measurement recorded beside it, which is the check
test.js's own comment asks for before raising. Verified the variable arrives:
both spawners use { ...process.env } and strip only ELECTRON_RUN_AS_NODE.

test.js already documented this on 2026-08-12, in the comment above the
constant. Three sessions rediscovered it from scratch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r key

Found while preparing the drive. nexus-1's env carries no ANTHROPIC_API_KEY, so
defaultProjector() returns UnconfiguredProjector and projectConcept answers
`unavailable` before the insert — whose own comment says it never claims a row,
so configuring a key later still works.

Two consequences are good news: the drive is free and consequence-free (no model
call, no money, no burnt (learner, concept) pair), and the before/after is still
unambiguous — 404 drawing nothing becomes 200 saying "not switched on for this
community yet". Silence becoming an honest sentence is what both halves of the
fix were for.

The third is not: the button still cannot explain anything until a key is
configured. That is a spend decision for Richard, not a defect to patch.

Also corrects something I had implied: concept_projections = 0 does NOT prove
the 404, because an unconfigured call leaves zero rows too. The 0-of-15
slug/teaches measurement is what proves it; the row count only shows no
projection was ever stored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bug 21 is fixed in both halves and deployed, and it exposed a third defect
that is Richard's spend decision: production has no ANTHROPIC_API_KEY, so the
button still cannot explain anything even though the mismatch and the silence
are both fixed. Costs measured from the real prompts: ~$0.03 per learner for
all 11 steps, once ever, ~$30 per thousand.

Test (editor) is a 900s cap inside test.js, not degradation and not the
listener leak. Three earlier readings all fit the evidence and were wrong;
the file that prints the message had documented it on 2026-08-12.

The handover leads with a measurement that is easy to destroy by accident:
run 32468326220 was still testing that fix, and pr.yml sets
cancel-in-progress, so a push kills it. That is why the previous commit is
deliberately unpushed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the learner for the lesson

Bug 18, two halves of three. Richard finished every step of *State on a page*
and was told "All 3 checked steps are done" and "the project has problems (21
reported)" in one breath, over 26 diagnostics that ship inside the lesson.

THE NUMBER WAS NEVER A COUNT. `capFindingLines` keeps MAX_FINDING_LINES = 20
and appends one line announcing the overflow — and that line is in the array.
So `findings.length` saturates: 26 problems print 21, and so would 1,000. The
`info` filter was a red herring; re-measuring the bundle gives 26 diagnostics
of which 26 are non-`info` (20 warnings, 6 errors). 21 was the cap, only the
cap. `buildLessonEvidence` carried the same saturated number to the platform as
`findingCount`, so the stored row was wrong too.

`WholeSolutionResult` gains `findingTotal`, counted before the cap in both
adapters — they have to agree, which is why they already cap at one number.
`findingTotalOf()` owns the read and falls back to the list length when an
adapter reports none: absent means "not reported", never "none found", and
under-reporting a broken project beats calling it clean. `normalise` bumps the
tally when it adds the empty-page finding and does not invent one when the
adapter was silent.

THE SENTENCE. With every checked step done it is now an observation about the
project beside the lesson's own verdict, not one verdict reading as failure.
The findings are still counted and still listed — the verdict is softened, the
finding is not, and a learner with a step outstanding gets the plain sentence.

Graded by mutation six ways; each goes red and restores byte-identical.
test:main 300 suites / 4884 tests / 0 failures (floor 4871 + 13 new), mcp 55
suites / 651 tests, four typechecks exit 0.

Still open in bug 18: the 26 diagnostics are in `state-on-a-page`, which is in
neither checkout and whose source path is gone — where it ships from has to be
answered first. And no gate validates a shipped lesson bundle at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ode halves closed

The measurement the last session held a commit to collect is taken: 2849 markers,
a real summary line, ten failures matching the floor by name, zero timeouts.

Records what is actually left in FIX-027, which is mostly not code — four of the
six open items are decisions for Richard, and both remaining code items need the
editor. Adds the two traps this session paid for: a capped list's length is not a
count, and a reading that fits is not one that excludes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runs quoted a moment earlier were cancelled by the push that added the
document quoting them — cancel-in-progress, the trap recorded two sections
above. Points at the live runs on a3a9be9 instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…already moved

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing in scripts/ or .github/ had ever looked at project-examples/ — the only
reference to it was the comment-legibility scanner. Bug 18 was found by a person
opening a lesson that carried 26 diagnostics they had no way to fix, because the
diagnostics were in the lesson's own graph rather than in anything it asked them
to build. Fourth time in this repo a checker existed and had never been pointed
at the corpus that ships.

npm run lessons:check walks every bundle under project-examples/lessons/<slug>/:
lesson.json parses and verifies against the node catalog, and BOTH projects —
the starter at the bundle root and solution/ — load and validate clean.
log-a-thing measures 0 manifest findings and 0 diagnostics across starter 3c/12n
and solution 3c/16n. The cardinality is printed on every run: a checker reporting
clean over an empty read scores identically to one over a perfect bundle.

Warnings fail this gate, unlike library:check, and that is the point. An unknown
node type arrives as severity: warning — exactly how the sibling gate reported
58/58 clean while nine shipped prefabs had no type at all. Gating on errors alone
would have left a hole shaped precisely like the defect. The strict bar is what
the corpus already meets.

Graded by mutation, in CI: --self-test copies the real bundle, breaks it seven
ways, and requires each break to be caught in the arm it NAMES rather than merely
to exit non-zero — a harness that threw would exit non-zero too. Verified by
meta-mutation that the reason check can fail. Control runs first; the shipped
corpus is never written to.

knownCollections is deliberately not supplied: omitted and supplied-empty are
different answers, and [] would fire unreachable-collection against every correct
data lesson. Deriving it properly is TUT-002 AC3, which is open.

Its own CI job rather than a step on library: three times this release a red step
was hidden behind a green one earlier in the same job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ading it taught

A content gate's SEVERITY THRESHOLD is where its hole goes: unknownNodeType is a
warning, so an errors-only gate reproduces bug 18's blind spot exactly — which is
what library:check's own header records as 58/58 clean over nine typeless prefabs.

And 'exited non-zero' is not 'caught': a mutation harness that throws reads as a
successful catch. The self-test asserts the arm each mutation was aimed at, and
was meta-mutated to prove that check can fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… withdrawn

The one shipped-source cluster the handover pointed at — +32 in
nodegx-node-kit-types/src/index.d.ts — cannot be typed. It is a hand-written
mirror whose any-sites are byte-identical to react-component-node.ts, and its
two stated rules (self-contained; must not lie) each forbid a different half of
the fix. Nothing would catch a mistake either: drift.test.js records
members[name] = isOptional, a boolean, so it is blind to member types.

Also: Test (editor) is at the floor on a THIRD seed (72336), 2849 specs, ten
failures matching by name; and the slow-spec count was measured on a truncated
run — it is 82, not 59, with seven suites at 79% of the wall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…found again

Model.prototype.on warns on '>' 10000, so every on() past the line prints — the
12,636 lines are one crossed threshold, not 12,636 leaks. The cost is real:
notifyListeners walks the whole array per event, so past 10,000 every event is
an O(10,000) scan. And a model holding >10,000 listeners survives from spec
#1219 to spec #2848, so it is never torn down between specs.

But causation is still unestablished. The mean triples across the boundary
while the MEDIAN STAYS FLAT, the flood begins inside BEN-001 (so "after" and
"slow suite" are the same population), and the within-suite trend contradicts
itself — 384x slower for AIX-011, 0.07x for AIX-002. The control that would
settle it needs a quiet machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… on a 4th seed

Lesson bundles (FIX-027) ran on CI for the first time and passed — and it
printed its cardinality (starter 3c/12n, solution 3c/16n; 6 components, 28
nodes), which is what separates a gate that checked something from one that
found nothing. Its self-test caught all seven mutations in CI, each in the arm
it names, including the empty-corpus exit 2.

Test (editor) on the pushed tree: 2849 specs, 10 failures, 0 timeouts, seed
68471 — a fourth unrelated seed, same four suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ere loaded

Test (editor) came back 13 against a floor of 10, and the three extra were all
BEN-001 harness-export specs reading `inputs.length = 0`. Not a regression: the
PR merge tree and the branch tree are the same object (b9ce3f9), and the two
runs differ only in the order jasmine drew.

`benchInterface` resolves `node.type` against the global NodeLibrary, so a
`Component Inputs` node the library has never heard of resolves to the unknown
type, carries no `haveComponentPorts`, and `ComponentModel.getPorts()` walks
past it — an empty interface, and therefore no parameters on the mounted node.
Nothing loads that library at start-up; fourteen suites each install the fixture
blob in their own beforeEach, and this file relied on one of them being drawn
first.

The control is inside a single run. At seed 22715 the harness-export specs ran
at ordinal 208 and failed; the earliest library-loading suite ran at 508; and
the SAME assertion passed at ordinal 574 from the interface describe, which had
only ever passed because it was drawn late. At seed 69883, same tree, BEN-001
landed at 1009 and the run was the floor.

Both interface-deriving describes now load the library themselves, and the
helper asserts the type is there so an empty interface fails where the cause is
rather than in an assertion about ports. The seed is pinned for ONE run to grade
this against the order that broke it; the next commit removes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… comes out

Removes NOODL_SPEC_SEED from pr.yml — it was pinned for exactly one run, which
graded the BEN-001 fix against the order that broke it: same seed 22715, same
ordinals (harness-export 208, interface 574, earliest library load 508), 13
failures before and 10 after, the floor name for name.

Records it as §2b⁷. The part worth carrying: "2849 / 10" was quoted across four
seeds as the floor, and a spec depending on ambient global state is invisible
until the draw exposes it — three of those four seeds simply drew kindly. Read
the failure NAMES; a count that matches is not a set that matches.

Also corrects two things I nearly left overstated: thirteen suites load the
library in this bundle, not fourteen (the fourteenth is a jest file in another
process), and the four tests/components/*ports.js files carrying the identical
hazard are not "checked and safe" — every spec in all four is xit, 0 active of
13. They cannot fire, which is a different fact and the true one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ince D13

SUB-011's three "the validator stays silent" specs are three of the floor's ten
failures, and they are the guard catching a real regression rather than stale
expectations.

The `fx` toggle stores `{mode:'expression',expression,fallback,version}` on a
port still typed string/number, and the typed runtime evaluates it and coerces
the result (`noodl-runtime/src/node.ts:32,60`). D13 (`c1c0b5b5`, 2026-08-18)
registered `rules/parameterValue`, so `SemanticValidator` began checking those
objects against the port's primitive shape: the corpus fixture went from silent
to 5 errors + 9 warnings, and `InvalidParameterValue` is the code that blocks.

SUB-011 predicted this on 2026-07-24, three weeks before the check existed:
"if it ever grows parameter/type checking, the object form reads as invalid
without an explicit carve-out (the same shape as the existing dynamic-port
carve-outs)". This is that carve-out, placed beside the connection-only skip
for the same stated reason — the value's shape is beside the point — and using
`ExpressionParameter.ts`'s own guard, which that task says to reuse rather than
reimplement. The port-existence check deliberately still runs first.

Measured, not inferred: the fixture reads 0/0 on all three spec paths
(plain, strict, after a round-trip). Graded by mutation — with 13 expression
objects made malformed (`expression: 42`, so the guard correctly declines
them) the reading returns to 5 errors + 8 warnings, so the carve-out skips
only well-formed expressions.

The fixture's `textAlign` was a real defect and is now `textAlignX`: Text
declares textAlignX/textAlignY and `textAlign` is only the CSS property the
node sets internally, so it stood in for "a plain parameter" while naming
nothing. Same dead parameter noodl-preview's fixture carried.

D13 is in v0.2.0 and was not in v0.1.7. The unpublished draft ships this.

typecheck:editor 0 · typecheck:editor-tests 0 · eslint 0 · test:main 300
suites / 4884 tests, 0 failures · MCP gate tests 34/34. The three jasmine
specs are CI-graded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d ruled out

Two more of the floor's ten, and the failing spec was right: F35 fixed the
list and left the default behind.

LAS-011 / F35 gave `openai-compatible` its own entries so a DeepInfra or vLLM
user is no longer offered `gpt-4.1`, an id their gateway does not serve. But
`getDefaultModel` is `find(isDefault) || models[0]`, and the OpenAI entries
that follow carry `isDefault` — so the `models[0]` fallback never ran and the
PRESELECTED model was `gpt-4.1` anyway. Measured before the change:
`getDefaultModel('openai-compatible')` → `gpt-4.1`, and the provider owned 0
defaults where every other provider owned exactly 1.

`deepseek-ai/DeepSeek-V4-Pro` is now the gateway default — DeepSeek rather than
Qwen because Richard's recorded verdict names it first ("DeepSeek v4 or the
latest Qwen, the big ones") and it carries four times the context window. So
"exactly one default per provider that owns models" now reads 1, which is the
invariant that was pointing at this.

The catalogue spec asserted plain equality with OpenAI's list — the behaviour
F35 deliberately ended, so it had become an assertion that the bug was still
there. Rewritten to the real contract: own entries FIRST, the OpenAI catalogue
after (Azure and similar shims do serve those ids), plus the half that matters
to a gateway user — what they are given by default is one of their own ids.

Measured after: all four providers own exactly one default, the list is
[...own, ...openai], and the default is DeepSeek. typecheck:editor 0 ·
typecheck:editor-tests 0 · eslint 0 · test:main 300 suites / 4884 tests, 0
failures. The two jasmine specs are CI-graded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ent" was too strong

Records §2b⁸: the ten failures are not ten independent items. SUB-011 ×3 is D13
reaching the shipped fx expression form with no carve-out (and it ships in
0.2.0); AI model registry ×2 is F35's surviving half, where a gateway user's
DEFAULT was still gpt-4.1. Both suites were the guard working.

Also reconciles phase-74 §6a, flagged by a peer session: "these ten are
deterministic and order-independent across four seeds" is true of these ten but
must not be read as "order does not matter here" — 1877dbd found BEN-001
depending on ambient global state that three of four seeds drew kindly around.
Adds a fifth seed (16562, run 32479979252) and the warning that the floor is
now moving.

Both of my own wrong readings are recorded beside the right one, including a
control that applied 0 mutations and read exactly like the valid case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-measured the installed state-on-a-page bundle on a tree carrying dae76da,
which stopped the validator reading the shipped fx expression form as invalid.
The count drops 26 -> 25: the one that went is the lone invalid-parameter-value
WARNING. The six errors are untouched, so the section's argument is unchanged —
21 was the display cap and not a count, and the bundle still ships dirty.

Added as a dated note rather than by rewriting the numbers, because the 20 + 6 =
26 arithmetic is load-bearing in the reasoning above it and was correct when it
was taken.

Worth carrying: a corpus measurement inherits the defects of the instrument that
took it. My original 26 was measured with a validator that had a real bug in it,
and that bug was one of test:ci's ten floor failures — which had been read as
accepted noise rather than as a guard that was firing correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…three

The remaining five floor failures are one defect. Since D13 (08-18) both the
semantic validator's report and `authoredPreconditionDiagnostics` run
`checkParameterValues`, so every caller that MERGES them doubles every
parameter-value finding. A rejection naming one mistake twice reads as two
mistakes: `AIX-011` counted 2 blocking warnings where the agent caused 1, and
`AIX-006`'s candidate burned its repair rounds being rejected for "2 problem(s)"
that were the same problem printed twice.

CN-009 AC5's drive found this on the day D13 landed and fixed it — in
`noodl-mcp/src/validate.ts` only (`810478ce`). Its own comment names the
editor's authoring loop as a caller that runs the preconditions, and the
editor's loop and `planTools.ts` kept doubling for three days.

So `dedupeDiagnostics` moves to `validation/authoredCandidate.ts`, beside the
`diagnosticKey` it is defined in terms of and under that file's own stated rule
— "three copies that agree are still three copies; this is the one" — and all
three callers now use it. The MCP copy is deleted rather than left to drift.

Also corrects the comment above the editor's join, which still justified it with
"the semantic validator has no view of parameter VALUES — its normalized model
does not carry them". D13 ended that; it is the same expired sentence
`parameterValues.ts` already carries a correction for.

Both baseline paths were checked and left alone: they collapse into a Set, so
duplicates never reached them.

typecheck:mcp 0 (the stricter one) · typecheck:editor 0 ·
typecheck:editor-tests 0 · test:main 300 suites / 4884 tests, 0 failures ·
noodl-mcp 55 suites / 651 tests, 0 failures. The five jasmine specs are
CI-graded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… are D13

Records §2b⁹: the ten Test (editor) failures reduce to four causes, three of
them D13. Names what is still open — ea40285 is not CI-graded yet, and
SUB-011's posture decision is still an empty placeholder — and the method notes
that cost this session time, including a mutation control that applied zero
mutations and read exactly like the valid case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run 32482182225 (seed 93591) on a46b52b: 2849 specs, 6 failures. The 3x
SUB-011 and 2x AI model registry are gone by name.

The sixth was never in the ten: AIX-011 criterion 7 (createPlanDocWriter). It
passed in the two prior runs, uses a fresh mkdtemp dir, and settles async undo
writes with a fixed 30ms setTimeout that a loaded runner loses. None of its
imports is in the diff. Recorded as a real fragility to fix at the constant,
not at the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`log-a-thing` was unfinishable by construction, and the first actionable step
was where it stopped. Found by driving it, 2026-08-21.

The chain: steps 1 and 6 grade against the built-in database
(`collectionExists`, `hasColumns`, `rowCountAtLeast`); nothing bound a backend
to an installed lesson; and `Backend Services` — the only surface that can
create or bind one, and the only route to the Schema and Data surfaces
(`backendSurfaces.tsx`) — was registered `isDisabled: isLesson === true`. So
grading answered "this project is not bound to a backend, so there is no
built-in database to read", and the panel that would have fixed it was greyed
out. The learner met what reads as a malfunction on step 1 of 8.

Two halves, because either alone leaves it broken:

1. `models/lessonbackend` gives the lesson its database on open — create (or
   adopt), start, and report the binding, which `EditorPage` applies through
   `setCloudServices` so the panel's `cloudServicesChanged` listeners see it.
2. `backend-services` is no longer disabled for a lesson that grades against
   the database. It is the only lesson exception in the rail, and by the time a
   learner opens it they meet their own lesson database rather than an empty
   panel.

Which lessons need one is DERIVED from their own grading conditions
(`lessonObservesDatabase`), never a hand-written `requiresBackend:` field —
`lessonprotection.ts` records the reason: a second statement of the same fact
drifts on the first edit, and here it would drift into a lesson grading against
a database it was never given.

🔴 It deliberately does NOT create the collections, and there is a spec whose
whole job is to say so. Step 1 *is* "make somewhere to put them"; a starter
shipping `LogEntries` pre-made makes it already-complete before the learner
arrives — the F2 defect `lessonbundleverify` exists to catch, arriving through
the one blind spot it admits to, since it reports database steps `not-checked`.
A green bundle gate would not have caught it.

`findReusableBackend` and `LocalBackendMeta` move to `BackendServices/backendReuse`,
a leaf, because `provisionBackend` reaches `projectmodel.editor` → `PopupLayer`
→ React and cannot be imported from a plain-Node runner. Extracted, not copied:
`noodl-mcp` already carries a third implementation, documented as verbatim.

The lesson text was also wrong and is corrected: it promised the Data panel
"offers to make one. Say yes", an affordance that does not exist — the empty
state says "Create one in Backend Services". It now names the real path.

DRIVEN, not read. On a real editor with the project deliberately unbound:
`[lesson] database created at http://localhost:8587`, the port is LISTENING,
the panel opens on "Log a thing database · Built-in • Port 8587 · ACTIVE ·
Running · This project uses this backend", Schema and Data are both on the
card, and the lesson bar moved from the not-bound error to "Looking for records
in LogEntries".

typecheck:editor 0 · typecheck:editor-tests 0 · typecheck:mcp 0 · lint:ci 0 ·
tsfixme unmoved at +37/+125 · test:main 301 suites / 4890 tests, 0 failures ·
noodl-mcp 55 suites / 651 tests, 0 failures (serially; the parallel red was
contention with the live editor, and passes 12/12 alone).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… in the repo

41 commits have landed since the v0.2.0 tag, 14 of them fixes, and none is in
the draft's artifacts. Publishing it would ship a first-time user none of the
twelve bugs Richard filed, the dark-theme inks, the "21 problems" cap, the fx
false positive — or a Log a thing that can be finished at all.

PUBLISH-0.2.0.md leads with that decision and the evidence, then the re-cut
procedure, the 15 assets to check directly, clean-machine verification, and
what is red on purpose (Test (editor) at its floor of 4, Lint on the tsfixme
decision) so neither is misread as a blocker.

RELEASE-NOTES-0.2.0.md moves into the repo. The previous copy lived in a
session scratchpad and survived only by luck; it gains a section for the fixes
that came out of using the alpha, and two honest additions to Known
limitations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…an-machine check is left

The old draft was built from 6fdf9de and was 42 commits stale. Deleted,
re-tagged at the head of cline-dev, rebuilt: all six release jobs green, 15
assets, latest-mac.yml carrying all four mac files under one version, both mac
legs notarised and stapled. Still isDraft.

Also corrects §1a. It told the next person to run `ci:build:editor` locally
because `pr.yml` had not run for a week. It runs now, as the
`Build (viewer + editor bundles)` job, on the exact commit being tagged — so the
measurement already existed remotely and the local run was redundant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It describes a draft that no longer exists. Points at PUBLISH-0.2.0.md and says
which half of it is still true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… update modal too

The launcher's update dialog renders `release.body` verbatim as markdown in a
520px window (autoupdater.js sets `notes: release.body`; UpdateDialog reads
`chosen?.notes || state.releaseNotes`). There is no second field to author, so
these notes serve both surfaces and the opening is written to stand alone as the
modal's first screenful: summary, then the changelog/Discord/YouTube links,
then the detail.

Adds the plain-language changelog artifact link in the v0.1.7 house pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewritten from a survey of the actual v0.1.7..v0.2.0 range rather than from the
earlier draft, which covered roughly half of it. Restored:

- NodeGX Community / the university (UNI, 78 commits): one account that gates
  nothing, intake and personalised path, the tutor overlay, "check my work"
  handing work to the platform, the people directory, badges
- MCP lesson authoring (UNI-010): get_lesson_brief / create_lesson /
  check_lesson / derive_starter, and why derive_starter's subtraction makes
  ghostwriting structurally hard rather than merely checked for
- Phase 66's 23-of-24 user-test fixes, and its finding that most of the list was
  built machinery the user could not reach
- Phase 69's two stated principles, and what "20 of 20" does not mean

Adds to Known-and-open the two gaps the survey surfaced: the library is not
licence-audited, and the style-token editor is deliberately absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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