From 155a8ad51d21da51c72daf5cc9b3903541dd1b90 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:36:03 +0000 Subject: [PATCH 1/4] docs(spec): let the solution-blueprint module header speak for itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findModuleDocBlock()` rejects a header-zone doc block that is immediately followed by a declaration — TSDoc's own attachment rule, read back: such a block documents that symbol. In `ai/solution-blueprint.zod.ts` a single blank line separated the module header from `const SNAKE_CASE`, so the header was read as that regex constant's documentation and disqualified. The generators then fell through to the export-list fallback, and the published skill-index row stated a true fact about the file while saying nothing about its subject: - `.../ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, ... Give `SNAKE_CASE` the one-line doc it always deserved. That is a comment, not a declaration, so the preamble now ends there and the header — plan-first authoring, the `apply_blueprint` expansion — becomes the module's own block. The selector is untouched: it was deciding correctly under its own rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- packages/spec/src/ai/solution-blueprint.zod.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index c18426f837..a1cac7e014 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -21,6 +21,7 @@ import { FilterConditionSchema } from '../data/filter.zod'; * batch-draft. This is the safety valve for low-specificity input. */ +/** Machine-name pattern every object, field and option name in a blueprint must match. */ const SNAKE_CASE = /^[a-z_][a-z0-9_]*$/; /** From 2b11b75039dc6b7620a46f52bbaba2645655eafc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:42:57 +0000 Subject: [PATCH 2/4] test(spec): pin the blueprint pointer row to its subject, and regenerate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published row and the reference page both regain the file's own sentence: - `.../ai/solution-blueprint.zod.ts` — Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) Neither generator can see this class on its own — `check:skill-refs` and `check:docs` compare the artifact against the generator, and the generator reproduces the selector faithfully, so a generator-only check passes on the defect. The pin asserts the fact the artifact must state instead. Two legs that fail differently: the SOURCE leg reds the moment the separator between the header and `SNAKE_CASE` goes away; the CORPUS leg stays green through that and reds once an index is regenerated from a file whose header no longer qualifies. Both directions measured. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .../docs/references/ai/solution-blueprint.mdx | 14 +++ .../solution-blueprint-header-row.test.ts | 116 ++++++++++++++++++ skills/objectstack-ai/references/_index.md | 2 +- 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/spec/scripts/solution-blueprint-header-row.test.ts diff --git a/content/docs/references/ai/solution-blueprint.mdx b/content/docs/references/ai/solution-blueprint.mdx index 02d6761b72..6b906254cb 100644 --- a/content/docs/references/ai/solution-blueprint.mdx +++ b/content/docs/references/ai/solution-blueprint.mdx @@ -5,6 +5,20 @@ description: Solution Blueprint protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} +Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) + +The structured-output target an AI agent emits for a *high-level* goal +("build me a project-management system") instead of transcribing a field +list. It is a **simplified proposal shape** — deliberately lighter than the +full `ObjectSchema` / `ViewSchema` / `DashboardSchema`. +The `apply_blueprint` tool expands each entry into a proper metadata body +and stages it as a draft (so the per-type Zod schema still validates the +real artifact at write time). + +The blueprint is **never persisted on its own**: the agent presents it for +conversational confirmation/edit (cheap), and only on human approval does it +batch-draft. This is the safety valve for low-specificity input. + **Source:** `packages/spec/src/ai/solution-blueprint.zod.ts` diff --git a/packages/spec/scripts/solution-blueprint-header-row.test.ts b/packages/spec/scripts/solution-blueprint-header-row.test.ts new file mode 100644 index 0000000000..f079becba6 --- /dev/null +++ b/packages/spec/scripts/solution-blueprint-header-row.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin for WHAT the published pointer row for `ai/solution-blueprint.zod.ts` + * names — plan-first authoring, not a list of the symbols it happens to export. + * + * `build-skill-references.ts` describes each source by the module's own doc + * block (`lib/file-description.ts` selects it: top-level, in the header zone, + * documenting no symbol), and falls through to `Exports: …` when no block + * qualifies. This file HAD its header — ADR-0033 §4, the `apply_blueprint` + * expansion — but a single blank line was all that separated it from + * `const SNAKE_CASE`, so TSDoc's own attachment rule made it that regex + * constant's documentation and the selector disqualified it. The published + * index therefore stated a true fact ABOUT the file and nothing about its + * SUBJECT, on the very row whose job is to route an agent to the source for + * exact field shapes. + * + * Same root cause as #14441 pointing the other way: there the wrong block was + * published, here the right one was suppressed and a machine-generated list + * took its place. Both are the header-zone selector deciding against a header + * a human wrote — and in this direction the selector was RIGHT under its own + * rule, so the repair is in the source, not in the selector. + * + * No generator can see this class. `check:skill-refs` and `check:docs` compare + * the artifact against the generator, and the generator reproduced the + * selector faithfully — a generator-only check PASSES on the defect. So pin + * the fact the artifact must state, not the pipeline that states it. + * + * Two legs that fail DIFFERENTLY, which is why both exist. The SOURCE leg reds + * the moment the separator between the header and `SNAKE_CASE` is removed — + * no regeneration needed. The CORPUS leg stays green through that (it reads + * checked-in bytes, which only move when someone regenerates) and reds on the + * state this card found: an index regenerated from a file whose header no + * longer qualifies. MEASURED both ways in the fix's reverse verification. + */ + +import fs from 'fs'; +import path from 'path'; +import url from 'url'; + +import { describe, expect, it } from 'vitest'; + +import { findModuleDocBlock } from './lib/file-description'; + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '../../..'); +const SKILLS_DIR = path.resolve(REPO_ROOT, 'skills'); +const BLUEPRINT_SOURCE = path.resolve(HERE, '../src/ai/solution-blueprint.zod.ts'); + +/** + * The module's own opening sentence. Spelled out rather than derived from the + * source: deriving it would re-assert the generator's rule and say nothing + * about WHICH subject the row names, which is the whole defect. + */ +const BLUEPRINT_SENTENCE = 'Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring)'; + +/** The pointer path the generator writes for this source in every index. */ +const POINTER = 'node_modules/@objectstack/spec/src/ai/solution-blueprint.zod.ts'; + +/** First prose line of the block the generator would publish for a source. */ +const firstDescriptionLine = (source: string): string | null => { + const block = findModuleDocBlock(source); + if (block === null) return null; + const lines = block + .split('\n') + .map((line) => line.replace(/^\s*\*\s?/, '').trim()) + .filter((line) => line && !line.startsWith('@') && !line.startsWith('```')); + return lines[0] ?? null; +}; + +describe('ai/solution-blueprint.zod.ts — the module block describes the module', () => { + it('opens on the plan-first authoring sentence, not on `SNAKE_CASE`', () => { + const source = fs.readFileSync(BLUEPRINT_SOURCE, 'utf-8'); + expect(firstDescriptionLine(source)).toBe(BLUEPRINT_SENTENCE); + }); + + it('still documents `SNAKE_CASE` — the fix ADDS a symbol doc, it does not delete the header', () => { + // The cheapest way to satisfy the leg above is to delete the separator's + // reason for existing. `SNAKE_CASE` is what the header must not be glued + // to, and what a reader of this file still needs explained. + const source = fs.readFileSync(BLUEPRINT_SOURCE, 'utf-8'); + const documented = /\/\*\*[^\n]*\*\/\n(?:export )?const SNAKE_CASE\b/.test(source); + expect(documented).toBe(true); + }); +}); + +describe('published catalog — every pointer row for the blueprint names its subject', () => { + /** Every checked-in skill-index row pointing at `ai/solution-blueprint.zod.ts`. */ + const publishedRows = (): { file: string; description: string }[] => { + const rows: { file: string; description: string }[] = []; + for (const skill of fs.readdirSync(SKILLS_DIR)) { + const index = path.resolve(SKILLS_DIR, skill, 'references/_index.md'); + if (!fs.existsSync(index)) continue; + for (const line of fs.readFileSync(index, 'utf-8').split('\n')) { + const match = /^- `([^`]+)` — (.+)$/.exec(line); + if (match && match[1] === POINTER) { + rows.push({ file: path.relative(REPO_ROOT, index), description: match[2].trim() }); + } + } + } + return rows; + }; + + it('finds the rows at all', () => { + // Nothing parsed means nothing compared, and "no bad row" would read as + // green — the failure mode this whole file exists to refuse. + expect(publishedRows().length).toBeGreaterThan(0); + }); + + it('reads the blueprint sentence on every one of them, never the `Exports:` fallback', () => { + const offenders = publishedRows() + .filter((row) => row.description !== BLUEPRINT_SENTENCE) + .map((row) => `${row.file}: ${row.description}`); + expect(offenders).toEqual([]); + }); +}); diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index 09238a53e6..aaecef91b1 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -13,7 +13,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/ai/knowledge-source.zod.ts` — Knowledge Source — declarative metadata describing what to index and - `node_modules/@objectstack/spec/src/ai/model-registry.zod.ts` — AI Model Registry Protocol - `node_modules/@objectstack/spec/src/ai/skill.zod.ts` — Skill Trigger Condition Schema -- `node_modules/@objectstack/spec/src/ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, BlueprintSummaryOperationsSchema, BlueprintFieldSchema, BlueprintObjectSchema, BlueprintViewSchema +- `node_modules/@objectstack/spec/src/ai/solution-blueprint.zod.ts` — Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) - `node_modules/@objectstack/spec/src/ai/tool.zod.ts` — Exports: ToolSchema ## Transitive dependencies From 5fcd53c6d138cdfd50216b266dca133d42c2b705 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 00:06:06 +0000 Subject: [PATCH 3/4] chore(spec): declare the new pin's escaping reads, and add the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin reads `skills/*/references/_index.md` — outside its own package — so it belongs in `vitest.repo-tests.json` and runs under the `repo` project, whose hash moves with what it actually reads. Left undeclared it would run under `@objectstack/spec#test`, invisible to both the affected-subset filter and the turbo cache: green on every PR while red on main, which is the one failure this pin exists to refuse. `packages/spec` ships `src/**/*.zod.ts`, and `npm pack --dry-run` confirms the edited source is in the tarball (206 such entries ship; scripts/, skills/ and content/ ship none), so the change publishes and takes a patch changeset. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .../solution-blueprint-module-header.md | 23 +++++++++++++++++++ packages/spec/vitest.repo-tests.json | 1 + 2 files changed, 24 insertions(+) create mode 100644 .changeset/solution-blueprint-module-header.md diff --git a/.changeset/solution-blueprint-module-header.md b/.changeset/solution-blueprint-module-header.md new file mode 100644 index 0000000000..a91da82efd --- /dev/null +++ b/.changeset/solution-blueprint-module-header.md @@ -0,0 +1,23 @@ +--- +"@objectstack/spec": patch +--- + +`ai/solution-blueprint.zod.ts` publishes its own sentence again, instead of a list of the symbols it happens to export. + +The file always carried a real module header — ADR-0033 §4 plan-first authoring, and how the `apply_blueprint` tool expands each entry into a proper metadata body. But only a blank line separated that header from `const SNAKE_CASE`, and TSDoc's own attachment rule says a block belongs to the declaration it immediately precedes. The header-zone selector reads that rule back, so the header counted as the regex constant's documentation and was disqualified as the module's. Both generators then fell through to their export-list fallback, and the row published into the `objectstack-ai` skill index read: + +``` +- `…/ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, BlueprintSummaryOperationsSchema, … +``` + +A true statement about the file that says nothing about its subject — on the one row whose job is to send an agent to this source for exact field shapes. + +`SNAKE_CASE` now carries the one-line doc it always deserved. A comment is not a declaration, so the preamble ends there and the header becomes the module's own block. The published row and the public reference page both open on it: + +``` +- `…/ai/solution-blueprint.zod.ts` — Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) +``` + +The selector is untouched. Under its own rule it was deciding correctly, and a census of every source under `packages/spec/src` found this file to be the only one of its kind: 19 shipped `*.zod.ts` sources have a header-zone block sitting against a declaration, and in the other 18 that block genuinely documents the symbol it sits against (`Transport Protocol Enum` against `TransportProtocol`, `Shared history for this file` against `AGENT_HISTORY`). Only here did a module header sit against a constant it says nothing about. + +Neither generator can see this class — each compares its artifact against itself, and each reproduced the selector faithfully, so a generator-only check passes on the defect. A pin now asserts the content of the published row directly. diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 9e6156d2dd..65d15639b8 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -17,6 +17,7 @@ "scripts/references-banner.test.ts", "scripts/root-index.test.ts", "scripts/schema-tree-freshness.test.ts", + "scripts/solution-blueprint-header-row.test.ts", "scripts/strictness-ledger-doc.test.ts", "scripts/strictness-ledger.test.ts", "src/api/error-catalog-docs.test.ts", From 76361df5d4bf1547feeadd7e0655a0df7f3e42e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 00:26:25 +0000 Subject: [PATCH 4/4] chore(spec): move the pointer-row pin to its own branch The pin and its `vitest.repo-tests.json` declaration are a change to `packages/spec/scripts/`, i.e. to the generator side of this repo. Carried here they defeat the generated-artifact exception that would otherwise lift the `skills/**` path on this PR: the recompute that certifies the regenerated index would be running a tree that also modifies the generator it is trusting, so it cannot certify itself. What is left here is a pure `gen:skill-refs` / `gen:docs` regeneration beside the one-line source fix. The pin lands separately and is not weakened. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .../solution-blueprint-header-row.test.ts | 116 ------------------ packages/spec/vitest.repo-tests.json | 1 - 2 files changed, 117 deletions(-) delete mode 100644 packages/spec/scripts/solution-blueprint-header-row.test.ts diff --git a/packages/spec/scripts/solution-blueprint-header-row.test.ts b/packages/spec/scripts/solution-blueprint-header-row.test.ts deleted file mode 100644 index f079becba6..0000000000 --- a/packages/spec/scripts/solution-blueprint-header-row.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Pin for WHAT the published pointer row for `ai/solution-blueprint.zod.ts` - * names — plan-first authoring, not a list of the symbols it happens to export. - * - * `build-skill-references.ts` describes each source by the module's own doc - * block (`lib/file-description.ts` selects it: top-level, in the header zone, - * documenting no symbol), and falls through to `Exports: …` when no block - * qualifies. This file HAD its header — ADR-0033 §4, the `apply_blueprint` - * expansion — but a single blank line was all that separated it from - * `const SNAKE_CASE`, so TSDoc's own attachment rule made it that regex - * constant's documentation and the selector disqualified it. The published - * index therefore stated a true fact ABOUT the file and nothing about its - * SUBJECT, on the very row whose job is to route an agent to the source for - * exact field shapes. - * - * Same root cause as #14441 pointing the other way: there the wrong block was - * published, here the right one was suppressed and a machine-generated list - * took its place. Both are the header-zone selector deciding against a header - * a human wrote — and in this direction the selector was RIGHT under its own - * rule, so the repair is in the source, not in the selector. - * - * No generator can see this class. `check:skill-refs` and `check:docs` compare - * the artifact against the generator, and the generator reproduced the - * selector faithfully — a generator-only check PASSES on the defect. So pin - * the fact the artifact must state, not the pipeline that states it. - * - * Two legs that fail DIFFERENTLY, which is why both exist. The SOURCE leg reds - * the moment the separator between the header and `SNAKE_CASE` is removed — - * no regeneration needed. The CORPUS leg stays green through that (it reads - * checked-in bytes, which only move when someone regenerates) and reds on the - * state this card found: an index regenerated from a file whose header no - * longer qualifies. MEASURED both ways in the fix's reverse verification. - */ - -import fs from 'fs'; -import path from 'path'; -import url from 'url'; - -import { describe, expect, it } from 'vitest'; - -import { findModuleDocBlock } from './lib/file-description'; - -const HERE = path.dirname(url.fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(HERE, '../../..'); -const SKILLS_DIR = path.resolve(REPO_ROOT, 'skills'); -const BLUEPRINT_SOURCE = path.resolve(HERE, '../src/ai/solution-blueprint.zod.ts'); - -/** - * The module's own opening sentence. Spelled out rather than derived from the - * source: deriving it would re-assert the generator's rule and say nothing - * about WHICH subject the row names, which is the whole defect. - */ -const BLUEPRINT_SENTENCE = 'Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring)'; - -/** The pointer path the generator writes for this source in every index. */ -const POINTER = 'node_modules/@objectstack/spec/src/ai/solution-blueprint.zod.ts'; - -/** First prose line of the block the generator would publish for a source. */ -const firstDescriptionLine = (source: string): string | null => { - const block = findModuleDocBlock(source); - if (block === null) return null; - const lines = block - .split('\n') - .map((line) => line.replace(/^\s*\*\s?/, '').trim()) - .filter((line) => line && !line.startsWith('@') && !line.startsWith('```')); - return lines[0] ?? null; -}; - -describe('ai/solution-blueprint.zod.ts — the module block describes the module', () => { - it('opens on the plan-first authoring sentence, not on `SNAKE_CASE`', () => { - const source = fs.readFileSync(BLUEPRINT_SOURCE, 'utf-8'); - expect(firstDescriptionLine(source)).toBe(BLUEPRINT_SENTENCE); - }); - - it('still documents `SNAKE_CASE` — the fix ADDS a symbol doc, it does not delete the header', () => { - // The cheapest way to satisfy the leg above is to delete the separator's - // reason for existing. `SNAKE_CASE` is what the header must not be glued - // to, and what a reader of this file still needs explained. - const source = fs.readFileSync(BLUEPRINT_SOURCE, 'utf-8'); - const documented = /\/\*\*[^\n]*\*\/\n(?:export )?const SNAKE_CASE\b/.test(source); - expect(documented).toBe(true); - }); -}); - -describe('published catalog — every pointer row for the blueprint names its subject', () => { - /** Every checked-in skill-index row pointing at `ai/solution-blueprint.zod.ts`. */ - const publishedRows = (): { file: string; description: string }[] => { - const rows: { file: string; description: string }[] = []; - for (const skill of fs.readdirSync(SKILLS_DIR)) { - const index = path.resolve(SKILLS_DIR, skill, 'references/_index.md'); - if (!fs.existsSync(index)) continue; - for (const line of fs.readFileSync(index, 'utf-8').split('\n')) { - const match = /^- `([^`]+)` — (.+)$/.exec(line); - if (match && match[1] === POINTER) { - rows.push({ file: path.relative(REPO_ROOT, index), description: match[2].trim() }); - } - } - } - return rows; - }; - - it('finds the rows at all', () => { - // Nothing parsed means nothing compared, and "no bad row" would read as - // green — the failure mode this whole file exists to refuse. - expect(publishedRows().length).toBeGreaterThan(0); - }); - - it('reads the blueprint sentence on every one of them, never the `Exports:` fallback', () => { - const offenders = publishedRows() - .filter((row) => row.description !== BLUEPRINT_SENTENCE) - .map((row) => `${row.file}: ${row.description}`); - expect(offenders).toEqual([]); - }); -}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 65d15639b8..9e6156d2dd 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -17,7 +17,6 @@ "scripts/references-banner.test.ts", "scripts/root-index.test.ts", "scripts/schema-tree-freshness.test.ts", - "scripts/solution-blueprint-header-row.test.ts", "scripts/strictness-ledger-doc.test.ts", "scripts/strictness-ledger.test.ts", "src/api/error-catalog-docs.test.ts",