From 3085aa77d2f2fa5ea1eed516e9eb82e69aeec7a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 01:21:15 +0000 Subject: [PATCH 1/2] docs: base the worktree recipe on a freshly fetched origin/main The documented recipe branched off the LOCAL 'main' ref, which nothing in the recipe or its prose fetches, so a worktree created by following it literally starts as far back as whoever last happened to update that ref on a long-lived shared checkout. AGENTS.md's second copy of the recipe named no base ref at all, basing off whatever the shared checkout's HEAD happened to be. All three sites now fetch first and branch off origin/main. The two '-cmp ' recipes are left alone: they take an explicit ref argument from the caller and are correct as written. Both files sit at their shrink-only ratchet ceilings with zero headroom, so the prose was reflowed to pay for the added text in place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- AGENTS.md | 14 +++++++------- CLAUDE.md | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7785a2f80b..bfbdd1ffe5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,7 +170,7 @@ Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`. When renaming a legacy var, use `readEnvWithDeprecation('OS_NEW', 'LEGACY')` from `@objectstack/types` (keeps legacy working one release). Third-party exceptions kept as-is: `NODE_ENV`, `HOME`, `OPENAI_API_KEY`, `TURSO_*`, OAuth `*_CLIENT_ID/SECRET`, `RESEND_API_KEY`, `POSTMARK_TOKEN`, `AI_GATEWAY_*`, `SMTP_*`. 10. **File issues for out-of-scope findings — don't silently expand scope or leave them buried.** When you hit a bug, gap, or unenforced capability that's unrelated to the current task, or too large to fix in scope, open a GitHub issue (`gh issue create`) with a clear repro/decision and link it from your PR. Corollary: **never advertise or demo a capability the runtime doesn't actually deliver** (declared ≠ enforced) — fix it, trim it, or file an issue, but don't fake coverage. The recurring shape: a spec declaring more rule types than the write-path validator enforced was closed by **trimming** what could never be enforced and **implementing** the rest — and even then the claim had to stay narrow, because the evaluator was wired into insert and single-id update while bulk update silently skipped every rule: the same declared-≠-enforced gap one layer down, at the **call site** rather than the `switch`. A `case` label is not enforcement; check the **call site**. -11. **Worktree-first — never edit on the shared `main` checkout.** This repo is edited by **multiple agents at once**; the shared tree has its HEAD switched and reset *under you*, silently clobbering uncommitted work — a feature branch on the *shared* checkout is **not** enough (it still gets switched under you). Before your **first file edit**, be in a dedicated worktree on a feature branch: `git worktree add ../objectstack- -b main && cd ../objectstack- && pnpm install`. Two PreToolUse hooks **enforce** this — `.claude/hooks/guard-main-checkout.sh` blocks `Edit`/`Write`/`NotebookEdit`, and `.claude/hooks/guard-main-checkout-bash.sh` blocks the identical write arriving through **Bash** (`>`/`>>` redirection, `sed -i`, `perl -i`, `tee`, `cp`, `mv`, `rm`, `touch`) — and both check the **target file's own repo**, so sibling repos (`objectui`/`cloud`) you touch are covered too (deliberate non-task override: `OS_ALLOW_MAIN_EDITS=1`, one switch for both). The Bash guard is precision-first: it never blocks reads, and any shape it cannot resolve with confidence (`bash -c …`, `xargs`, `node -e`, a `$VAR`/glob target) is allowed through — the rule still outranks the hook. **The one thing a worktree does *not* isolate is the stash** — `refs/stash` lives in the **common** `.git`, shared by every worktree; a third hook (`guard-shared-stash.sh`, `OS_ALLOW_STASH=1`) blocks the mutating forms, and the collision-free replacements are in Multi-agent discipline below. +11. **Worktree-first — never edit on the shared `main` checkout.** This repo is edited by **multiple agents at once**; the shared tree has its HEAD switched and reset *under you*, silently clobbering uncommitted work — a feature branch on the *shared* checkout is **not** enough (it still gets switched under you). Before your **first file edit**, be in a dedicated worktree on a feature branch: `git fetch origin main && git worktree add ../objectstack- -b origin/main && cd ../objectstack- && pnpm install`. Two PreToolUse hooks **enforce** this — `.claude/hooks/guard-main-checkout.sh` blocks `Edit`/`Write`/`NotebookEdit`, and `.claude/hooks/guard-main-checkout-bash.sh` blocks the identical write arriving through **Bash** (`>`/`>>` redirection, `sed -i`, `perl -i`, `tee`, `cp`, `mv`, `rm`, `touch`) — and both check the **target file's own repo**, so sibling repos (`objectui`/`cloud`) you touch are covered too (deliberate non-task override: `OS_ALLOW_MAIN_EDITS=1`, one switch for both). The Bash guard is precision-first: it never blocks reads, and any shape it cannot resolve with confidence (`bash -c …`, `xargs`, `node -e`, a `$VAR`/glob target) is allowed through — the rule still outranks the hook. **The one thing a worktree does *not* isolate is the stash** — `refs/stash` lives in the **common** `.git`, shared by every worktree; a third hook (`guard-shared-stash.sh`, `OS_ALLOW_STASH=1`) blocks the mutating forms, and the collision-free replacements are in Multi-agent discipline below. 12. **Contract-first — fix the metadata, not the runtime.** This is a metadata-driven framework: `packages/spec` is the one contract between metadata *producers* and the runtime/renderers that *consume* it. When a piece of metadata "doesn't work," ask **first**: *is it spec-compliant? is this the long-term-correct direction?* If the metadata is wrong, fix it at the **producer** and **reject it at authoring/publish** (validation / lint) so the error surfaces loudly — do **not** add a lenient alias or `??` fallback in a consumer (a node executor, the REST layer, a renderer) to tolerate off-spec input. A tolerant fallback fossilizes the wrong convention into a second de-facto contract, dilutes the spec, and hides the producer's bug — one strict contract beats N dialects. This is an **internal** contract (we own both ends), so "be liberal in what you accept" (Postel) does **not** apply — that's for untrusted boundaries. Change the **spec** only when the spec itself is genuinely wrong, and then deliberately (edit the Zod schema + migrate), never by accreting consumer-side fallbacks. When an alias must be tolerated at all, declare it as an **ADR-0087 conversion-layer entry** (never a bare `??`, and no executor shims) so it is declared, loud, tested, and *removable on a schedule* — the `cfg.filter ?? cfg.filters`-style fallbacks the flow executors once carried were all paid down exactly that way, emptying and deleting the executor shim that read them. Stored `sys_metadata` rows (data at rest) are covered from the other side: every rehydration seam replays the **full** conversion chain — retired entries included — via `applyConversionsToStoredItem` (ADR-0087 addendum), so a consumer never needs its own accommodation for a legacy stored shape either. *Worked example:* an AI-authored flow node used wrong key names and template syntax for what the executor reads → the fix was correcting the authoring skill + a publish-gate lint that rejects the wrong shape, **not** a runtime alias in the executor (that alias was proposed and rejected). Strengthens #5. 13. **An accepted ADR binds until a superseding ADR says otherwise.** Reversing a recorded decision is itself a decision: it needs a **new ADR** (or an amended status line on the old one), not a changeset that quietly does the opposite. Before changing behaviour in `docs/adr/`-governed territory, **grep the ADRs for the surface you are touching** — the decision is often older and broader than the code comment in front of you. A reversal of three accepted ADRs once landed as a patch-level changeset and held for a day; the mechanism was not carelessness — **the file being edited never named the ADRs that governed it**, so the author could not have known. Hence the corollary: when you implement an ADR's decision, **leave its id in the code**, and anchor load-bearing spots in `scripts/adr-anchors/` (`pnpm check:adr-anchors`) — **one new JSON file per anchor, named for the path it anchors; there is no index to register it in** — so the next author is told which decision they are standing on. A decision nobody can find is a decision that will be reversed. 14. **⛔ A governed surface is confirmed and merged by the maintainer, by hand — no AI seat merges, queues, or arms auto-merge on a PR whose diff touches one.** Three maintainer rulings, verbatim and untranslated — each widens the one before it, and the third *is* the current definition of the surface: @@ -200,12 +200,12 @@ Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`. ## Multi-agent working discipline This repo is worked on by **multiple agents in parallel**. **Use one git worktree per -agent/task** (`git worktree add ../objectstack- -b `; run `pnpm install` -in the new tree) so file systems are physically isolated — mandatory, not a preference -(Prime Directive #11), and hook-enforced. Working in the shared `main` checkout is *not* -a supported fallback: branches get switched and shared files — including ones you just -wrote — get reset *under you* mid-task (full sessions of work were silently reverted -this way before the rule was enforced). +agent/task** (`git fetch origin main && git worktree add ../objectstack- -b + origin/main`; run `pnpm install` in the new tree) so file systems are +physically isolated — mandatory, not a preference (Prime Directive #11), hook-enforced. +Working in the shared `main` checkout is *not* a supported fallback: branches get +switched and shared files — including ones you just wrote — get reset *under you* +mid-task (full sessions of work were silently reverted before the rule was enforced). **⛔ `git stash` is the one thing the worktree does NOT isolate — never run a bare `git stash push`/`pop`.** `refs/stash` lives in the **common** `.git` directory, so diff --git a/CLAUDE.md b/CLAUDE.md index de9ca6443d..3c3e561a75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ shared checkout is NOT enough** — it still gets switched under you. You MUST b **dedicated per-task worktree**: ``` -git worktree add ../- -b main && cd ../- && pnpm install +git fetch origin main && git worktree add ../- -b origin/main && cd ../- && pnpm install ``` Then make all edits there. This applies **per repo**: if a task spans `framework` and From 5fbbb95e208aeffdb7adb283f047f3a266297f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 01:28:26 +0000 Subject: [PATCH 2/2] fix(pm): make dispatch-gates say when it is deriving from a stale tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The banner names the commit an answer came from, which is exactly the fact that exposes a stale checkout — but it prints it in the same spelling a current checkout uses, so staleness arrives dressed as ordinary provenance. A long-lived shared checkout drifted far enough back that its on-disk copy of a check script predated a PR that changed that exact file; the run printed a well-formed verdict and exited 0 about a tree nobody is on. baseDrift() measures HEAD against origin/main and reports both the commit distance and whether anything the answer is DERIVED FROM (.github/workflows, package.json, scripts/) changed upstream. Only the second goes loud: a dev worktree falls a few commits behind within the hour, so a warning keyed on distance alone fires on nearly every honest run and stops being read. The upstream question is asked with a three-dot diff, so a dev's own edits to a check script are never reported back as staleness. The count is stated as a lower bound because origin/main is a local ref only a fetch moves. Zero drift prints nothing at all — against a base ref nobody refreshed, a clean bill of health is the reading the failure would have passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- scripts/pm/dispatch-gates.mjs | 146 +++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 6d1c0b5c4a..16b80724da 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -3710,6 +3710,96 @@ export function repoIdentity({ cwd = ROOT } = {}) { return { root: root ?? cwd, head, remote, slug: remote ? parseRepoSlug(remote) : null }; } +/** + * The files a derivation's ANSWER is made of — the ones whose staleness can + * change it. The gate inventory is read from the workflow files, `check:*` is + * resolved through the manifest, and the checks themselves live under + * `scripts/`. Everything else in the tree can be arbitrarily old without moving + * a single family, which is what makes this list the right filter and raw + * commit distance the wrong one. + */ +export const DERIVATION_SURFACE = ['.github/workflows', 'package.json', 'scripts']; + +/** + * How far behind `DEFAULT_BASE_REF` this checkout is — and whether that matters. + * + * `bannerLines` already names the commit an answer came from, which is the very + * fact that exposes a stale checkout — but it prints it in the same spelling a + * current checkout uses, so staleness arrives dressed as ordinary provenance. + * The measured failure: a long-lived shared checkout drifted far enough back + * that its on-disk copy of a check script predated a PR that had changed that + * exact file, and a run from it printed a well-formed verdict, exit 0, about a + * tree nobody is on. Nothing in the tool, the output or the workflow said so. + * + * Commit distance ALONE would be the wrong instrument. A dev worktree falls a + * few commits behind within the hour by construction, so a warning keyed on + * distance fires on nearly every honest run and stops being read — and a + * warning nobody reads reproduces the silence it was added to break. What + * decides whether the distance matters is narrower and just as cheap to ask: + * did anything in `DERIVATION_SURFACE` change across that range? So both are + * measured, and only the second one shouts. + * + * That second question is asked with a THREE-dot diff, which is the whole + * difference between reporting upstream work this tree is missing and + * reporting the caller's own edits back to them. A dev worktree that is a few + * commits behind AND has edited a check script is the ordinary case, and a + * two-dot diff would name that dev's own file as evidence the tree is stale. + * + * The count is a LOWER BOUND and says so. `DEFAULT_BASE_REF` is a LOCAL + * remote-tracking ref that only a fetch moves, so a checkout nobody fetches is + * measured against a base that is itself behind. Unfetched staleness can only + * make the true number bigger, never smaller — which is what lets this stay + * honest without the derivation reaching for the network. + * + * Every field degrades to null rather than throwing. No base ref, a shallow + * clone and no git at all are real states, and none of them is an error here. + */ +export function baseDrift({ cwd = ROOT } = {}) { + const read = (args) => { + try { + const r = runGit(args, cwd); + return r.status === 0 ? r.stdout.trim() : null; + } catch { + return null; // git itself unavailable — the banner degrades, it never throws + } + }; + const base = read(['rev-parse', '--short', DEFAULT_BASE_REF]); + if (base === null) return { base: null, behind: null, changed: [], headDate: null, baseDate: null }; + const counted = read(['rev-list', '--count', `HEAD..${DEFAULT_BASE_REF}`]); + const behind = /^\d+$/.test(counted ?? '') ? Number(counted) : null; + const names = behind ? read(['diff', '--name-only', `HEAD...${DEFAULT_BASE_REF}`, '--', ...DERIVATION_SURFACE]) : ''; + return { + base, + behind, + changed: names ? names.split('\n').filter(Boolean) : [], + headDate: read(['log', '-1', '--format=%cI', 'HEAD']), + baseDate: read(['log', '-1', '--format=%cI', DEFAULT_BASE_REF]), + }; +} + +/** + * Render the drift. Loud when it can have changed the answer, quiet when it + * demonstrably cannot, and SILENT at zero — the last one for the same reason + * the banner has no "all paths present" twin: against a base ref nobody + * refreshed, a clean bill of health is precisely the reading the measured + * failure would have passed. + */ +export function driftLines(drift) { + if (!drift || !drift.behind) return []; + const { behind, base, changed, headDate, baseDate } = drift; + const span = `HEAD${headDate ? ` ${headDate}` : ''} vs ${DEFAULT_BASE_REF} ${base}${baseDate ? ` ${baseDate}` : ''}`; + if (changed.length === 0) { + return [` At least ${behind} commit(s) behind ${DEFAULT_BASE_REF}, but nothing this answer derives from changed across that range — ${span}.`]; + } + return [ + ` ⚠️ STALE TREE — this answer is derived from a tree at least ${behind} commit(s) behind ${DEFAULT_BASE_REF}, and ${changed.length} file(s) it derives from CHANGED across that range.`, + ` ${span}`, + ` Stale here: ${changed.slice(0, 6).join(' ')}${changed.length > 6 ? ` … +${changed.length - 6} more` : ''}`, + ` Those files ARE the families printed below, so this run read their old copies and still exited 0 — a well-formed answer about a tree nobody is on.`, + ` "At least": ${DEFAULT_BASE_REF} is a LOCAL ref only a fetch moves. Run 'git fetch ${DEFAULT_BASE_REMOTE} ${DEFAULT_BASE_BRANCH}' and derive again from a tree at ${DEFAULT_BASE_REF}.`, + ]; +} + /** * Split argv into paths, flags and the repo assertion. * @@ -3810,7 +3900,7 @@ export function repoAssertionVerdict({ asserted, identity }) { * read as a clearance, and it is precisely the reading the measured failure * would have passed — its two paths exist in every repo in the family. */ -export function bannerLines({ identity, paths = [] }) { +export function bannerLines({ identity, paths = [], drift = null }) { const at = identity?.head ? ` at commit ${identity.head}` : ''; const who = identity?.slug ? `'${identity.slug}'${at} (${identity.root})` @@ -3819,6 +3909,7 @@ export function bannerLines({ identity, paths = [] }) { `dispatch-gates: gate list derived from the tree of ${who}.`, ` Families are a property of THAT repo. A card landing in another repo derives nothing here — assert with ${REPO_FLAG} to make this checkable.`, ]; + lines.push(...driftLines(drift)); const missing = paths.filter((p) => !p.includes('*') && !existsSync(join(identity?.root ?? ROOT, p))); if (missing.length > 0) { lines.push( @@ -6346,6 +6437,57 @@ function selfTest() { const bannerPresent = bannerLines({ identity: { ...hereIdentity, root: ROOT }, paths: ['packages/spec/src/index.ts'] }); t('all paths present prints NO clearance line — absence and clearance must not share a spelling', !bannerPresent.join('\n').includes('absent from this tree') && bannerPresent.length === 2); + // ── Base drift (#11540) ─────────────────────────────────────────────────── + // The banner names the commit an answer came from; on a stale checkout that + // reads as ordinary provenance. These pin the loudness, and pin that the + // quiet cases stay quiet — a warning on every honest run is a warning nobody + // reads. + t('no measurable base ref prints nothing rather than guessing', driftLines(null).length === 0 && driftLines({ base: null, behind: null, changed: [] }).length === 0); + t('a tree level with the base prints NO clearance — the failure would have passed one', driftLines({ base: 'aaaaaaa', behind: 0, changed: [] }).length === 0); + const benign = driftLines({ base: 'aaaaaaa', behind: 7, changed: [], headDate: '2026-01-01T00:00:00Z', baseDate: '2026-01-02T00:00:00Z' }); + t('behind, but with the derivation surface untouched, states the distance in ONE quiet line', benign.length === 1 && benign[0].includes('7 commit(s) behind')); + t('and that quiet line does not cry stale, so the loud spelling stays rare', !benign.join('\n').includes('STALE TREE')); + const loud = driftLines({ base: 'aaaaaaa', behind: 120, changed: ['scripts/pm/dispatch-gates.mjs', '.github/workflows/lint.yml'], headDate: '2026-01-01T00:00:00Z', baseDate: '2026-01-08T00:00:00Z' }); + const loudText = loud.join('\n'); + t('a changed derivation surface is LOUD, and names what it compared', loudText.includes('STALE TREE') && loudText.includes('HEAD') && loudText.includes(DEFAULT_BASE_REF) && loudText.includes('120 commit(s)')); + t('it names the stale files themselves, not just a count', loudText.includes('scripts/pm/dispatch-gates.mjs') && loudText.includes('.github/workflows/lint.yml')); + t('it says the exit code is no defence — the measured failure exited 0', loudText.includes('exited 0')); + t('the count is a LOWER bound, because the base ref is local and only a fetch moves it', loudText.includes('At least') && loudText.includes(`git fetch ${DEFAULT_BASE_REMOTE} ${DEFAULT_BASE_BRANCH}`)); + t('drift reaches the banner, and stays behind the repo line that must come first', bannerLines({ identity: hereIdentity, paths: [], drift: { base: 'aaaaaaa', behind: 9, changed: ['scripts/x.mjs'] } })[0].includes('gate list derived from the tree of')); + t('and a banner given no drift is byte-identical to before the flag existed', bannerLines({ identity: hereIdentity, paths: [], drift: null }).join('\n') === bannerLines({ identity: hereIdentity, paths: [] }).join('\n')); + + const driftTmp = mkdtempSync(join(tmpdir(), 'dispatch-gates-drift-')); + try { + const gd = (args, cwd) => spawnSync('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', ...args], { cwd, encoding: 'utf8' }); + const up = join(driftTmp, 'upstream'); + mkdirSync(up, { recursive: true }); + gd(['init', '-q', '-b', DEFAULT_BASE_BRANCH], up); + writeFileSync(join(up, 'seed.txt'), 'seed\n'); + gd(['add', '-A'], up); gd(['commit', '-qm', 'seed'], up); + const clone = join(driftTmp, 'clone'); + gd(['clone', '-q', up, clone], driftTmp); + // Positive control: a clone level with its base must read zero, or a + // non-zero reading below proves nothing. + t('a checkout level with its base measures zero drift (positive control)', baseDrift({ cwd: clone }).behind === 0); + // Upstream moves in a file the answer is NOT derived from. + writeFileSync(join(up, 'seed.txt'), 'seed2\n'); + gd(['add', '-A'], up); gd(['commit', '-qm', 'unrelated'], up); + gd(['fetch', '-q', DEFAULT_BASE_REMOTE], clone); + const offSurface = baseDrift({ cwd: clone }); + t('drift against a real repo is measured from git, never assumed', offSurface.behind === 1 && !!offSurface.base); + t('and a commit outside the derivation surface leaves the loud list empty', offSurface.changed.length === 0 && driftLines(offSurface).length === 1); + // Now upstream moves a file the answer IS derived from — the measured shape. + mkdirSync(join(up, 'scripts'), { recursive: true }); + writeFileSync(join(up, 'scripts', 'check-thing.mjs'), 'export const a = 1;\n'); + gd(['add', '-A'], up); gd(['commit', '-qm', 'change a check script'], up); + gd(['fetch', '-q', DEFAULT_BASE_REMOTE], clone); + const onSurface = baseDrift({ cwd: clone }); + t('a commit INSIDE the derivation surface is caught and named', onSurface.behind === 2 && onSurface.changed.includes('scripts/check-thing.mjs')); + t('and that is the case that goes loud', driftLines(onSurface).join('\n').includes('STALE TREE')); + } finally { + rmSync(driftTmp, { recursive: true, force: true }); + } + const idTmp = mkdtempSync(join(tmpdir(), 'dispatch-gates-id-')); try { const gi = (args, cwd) => spawnSync('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', ...args], { cwd, encoding: 'utf8' }); @@ -6590,7 +6732,7 @@ if (invokedDirectly) { // which repo it is about — the one thing the silent wrong answer never said. const identity = repoIdentity(); const declaredPaths = argvPaths.map((p) => p.replace(/^\.\//, '')); - for (const line of bannerLines({ identity, paths: declaredPaths })) console.error(line); + for (const line of bannerLines({ identity, paths: declaredPaths, drift: baseDrift() })) console.error(line); if (argv.assertion !== null) { // An assertion the tree contradicts is the measured failure, caught. It // ends the run: a caller that named the repo it needs has stated a