diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a837908d90..d051ac3e13 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -35,12 +35,12 @@ Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; t ## Package Model Experience -Every package README ends with this table immediately before `## Known Limitations and Deferred Work`; [allowlisted packages](../scripts/verify-readme-limitations.ts) end after it: +Every package README ends with `## Model Experience` immediately before `## Known Limitations and Deferred Work`; [allowlisted no-limitations packages](../scripts/verify-readme-limitations.ts) end after Model Experience. Packages with direct, multi-surface, conditional, capped, or lifetime effects use this table: | Context surface | What the model sees | Token effect | |---|---|---| -Rows state what reaches which model and classify token cost or lifetime. Treat prompt text and tool schemas separately when their visibility conditions differ; zero-direct rows name the indirect path. `verify-package-readme-model-experience` gates shape and order, while review owns accuracy ([rationale](rfc/implemented/process/2026-07-12-package-model-experience-contract.md)). +Rows state what reaches which model and classify token cost or lifetime; prompt text and tool schemas stay separate when visibility differs. Packages explicitly classified in [`SENTENCE_MODEL_EXPERIENCE`](../scripts/verify-package-readme-model-experience.ts) instead carry one sentence beginning `None, as ` or `Indirectly, through ` and ending with a period. The verifier gates the sentence allowlist, table shape, and section order; review owns factual accuracy ([rationale](rfc/implemented/process/2026-07-12-package-model-experience-contract.md)). ## Wordcount Budgets diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index d8def5ebfe..b87baf8aba 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -14,7 +14,7 @@ packages/// src/index.ts # service default export or plugin (name/inject/apply/Config) tests/.spec.ts README.md # service API, events, extension points, design notes, - # + the required Model Experience table + # + the gated Model Experience table or short sentence # + the gated "Known Limitations and Deferred Work" section # (or a whitelist entry in scripts/verify-readme-limitations.ts) ``` @@ -56,7 +56,9 @@ Keep package-specific service API, config, events, extension points, and design - **Consumer-visible gap** — exact boundary or deliberately deferred work. ``` -Fill [Model Experience](../AGENTS.md#package-model-experience) from the implementation: name every direct request contribution and token-growth condition, or state zero direct tokens and its indirect path. Do not infer prompt visibility from tool-schema visibility; independently registered guidance can remain after a scoped tool restriction. Every package participates, including type-only libraries and backend seams. A package with genuinely no limitations joins the justified allowlist in [`verify-readme-limitations.ts`](../../scripts/verify-readme-limitations.ts) and ends after Model Experience. +Fill [Model Experience](../AGENTS.md#package-model-experience) from the implementation. Direct, multi-surface, conditional, capped, or lifetime effects use the table; name each request contribution and token-growth condition. Do not infer prompt visibility from tool-schema visibility because independently registered guidance can remain after a scoped tool restriction. + +An audited package with no context effect or one simple consumer-owned path joins [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) and replaces the table with one line beginning `None, as ` or `Indirectly, through `. Every package outside that allowlist must keep the exact table. A package with genuinely no limitations joins the separate allowlist in [`verify-readme-limitations.ts`](../../scripts/verify-readme-limitations.ts) and ends after Model Experience. ## 5. Verify diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md index 8fb3e45a83..76dcb15763 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md @@ -8,20 +8,21 @@ A package README can explain APIs and runtime mechanics without answering the qu ## Decision -Every workspace package README ends with the canonical [Model Experience table](../../../AGENTS.md#package-model-experience), immediately before `## Known Limitations and Deferred Work`; a package on the no-limitations allowlist ends with Model Experience itself. Each row identifies a concrete request surface, says what the relevant model literally receives and when, and classifies the token effect. The default subject is the conversation model; a package that invokes an auxiliary model, such as a summarizer or search provider, names that request separately. Agent-scoped visibility is stated where it changes which agent receives a contribution. Prompt text and tool schemas are described separately whenever configuration or scoping can hide one without the other. +Every workspace package README ends with the canonical [Model Experience section](../../../AGENTS.md#package-model-experience), immediately before `## Known Limitations and Deferred Work`; a package on the no-limitations allowlist ends with Model Experience itself. Packages with direct, multi-surface, conditional, capped, or lifetime effects use the three-column table. Each row identifies a concrete request surface, says what the relevant model literally receives and when, and classifies the token effect. The default subject is the conversation model; a package that invokes an auxiliary model, such as a summarizer or search provider, names that request separately. Agent-scoped visibility is stated where it changes which agent receives a contribution. Prompt text and tool schemas are described separately whenever configuration or scoping can hide one without the other. -Every package participates. A service seam, storage backend, test helper, or type-only library that contributes no prompt text, tool schema, message, or auxiliary request records zero direct tokens and names the consumer or control path through which it can still change model-visible material. This explicit negative contract prevents readers from having to infer whether the section was forgotten. +Every package participates. A package with no model-context effect, or one simple effect rendered entirely by another package, can join the verifier's audited sentence allowlist. It then uses exactly one sentence beginning `None, as ` or `Indirectly, through ` instead of stretching a negative fact across a three-column table. Implementations that shape results, caps, history, lifetimes, or more than one request surface keep the table even when they add zero direct prompt tokens. -`verify-package-readme-model-experience` discovers packages from `packages/*/*/package.json`, requires one sibling README, the canonical final-section order, one exact `## Model Experience` heading and three-column table header outside fenced code, and at least one complete row. It runs in `doc-sync` and the parallel gate runner. The check owns structural presence, shape, and order; implementation review owns coverage and the truth of the prose. +`verify-package-readme-model-experience` discovers packages from `packages/*/*/package.json`, requires one sibling README and the canonical final-section order, and validates one of two package-classified bodies outside fenced code. An allowlisted package must carry exactly one sentence with its assigned prefix; every other package must carry only the exact three-column header and at least one complete row. It runs in `doc-sync` and the parallel gate runner. The check owns package classification, structural presence, shape, and order; implementation review owns coverage and the truth of the prose. ## Alternatives considered - **Document only packages that register prompts or tools** — rejected because backends, policy plugins, adapters, persistence, scoping, and compaction change the content or lifetime of tokens without owning a model-facing schema. - **Generate one central context-cost catalog from source** — rejected because an AST can find registrations but cannot infer semantic conditions such as history retention, output truncation, parent-versus-child visibility, or an auxiliary model boundary. The package README is the implementation-local contract; a central copy would add another drift surface. - **Require numeric token counts** — rejected because exact counts depend on the selected model tokenizer, adapter serialization, configuration, and runtime data. The stable contract is the growth shape: fixed per request, conditional per call, retained, replaced, capped, or zero-direct. -- **Allow zero-impact packages to omit the section** — rejected because absence is ambiguous between an audited zero and forgotten documentation. One explicit row is cheap and mechanically distinguishable. +- **Allow zero-impact packages to omit the section** — rejected because absence is ambiguous between an audited zero and forgotten documentation. One explicit sentence is cheap and mechanically distinguishable. +- **Require the full table for audited zero or simple indirect packages** — rejected because it spreads one fact across three cells and encourages repetitive prose. A gated sentence preserves explicit coverage without the ceremony. - **Convention without a gate** — rejected because a repo-wide contract must also cover every future package; review memory cannot reliably detect an omitted README section. ## Consequences -A reviewer can start at any package and see its contribution to the conversation model, child models, and auxiliary calls without reconstructing the full plugin graph. Token-budget work can distinguish repeated request overhead from data-dependent history, and agent-scoped changes have an explicit documentation checkpoint. Package authors pay for one small table and must update it whenever model-visible behavior changes. The table deliberately does not promise provider-exact token counts; measurements remain model- and workload-specific, while the documented growth and visibility contract stays stable. +A reviewer can start at any package and see its contribution to the conversation model, child models, and auxiliary calls without reconstructing the full plugin graph. Token-budget work can distinguish repeated request overhead from data-dependent history, and agent-scoped changes have an explicit documentation checkpoint. Package authors pay for one small table or one classified sentence and must update it whenever model-visible behavior changes. Tables deliberately do not promise provider-exact token counts; measurements remain model- and workload-specific, while the documented growth and visibility contract stays stable. diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index 97c1b91dff..ddb9b92d1d 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -38,9 +38,7 @@ The seam also owns the per-session mode override vocabulary (the sandbox RFC § ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | This interface registers no prompt, tool schema, or message. `dsh-tool-bash` turns an implementation's stdout, stderr, task state, and sandbox facts into model-visible tool results and guidance. | Zero direct tokens. Result size and sandbox state affect input tokens only when a consumer renders them. | +Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens. ## Known Limitations and Deferred Work diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index 9c256a31dc..94c3bad61c 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -20,9 +20,7 @@ Semantics every implementation must honor (contract details in the class JSDoc): ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | The seam receives a program and host bindings but registers no prompt, schema, or message. Code Mode in `dsh-tools` exposes the SDK and `run_code`, then converts `CodeRunResult` into the outer tool result. | Zero direct tokens. Program logs, values, and failures affect the conversation only through the Code Mode consumer. | +Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens. ## Known Limitations and Deferred Work diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 851a509cfd..bad4133b72 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -45,9 +45,7 @@ This package declares three events (see the generated [events catalog](../../../ ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | The provider seam registers no prompt or tool. `dsh-tool-fs` converts provider text and structured `FsError` values into model-visible read, write, and edit results; policy listeners can change which outcome it receives. | Zero direct tokens. File content and errors enter context only through a consumer, whose window and byte caps determine result size. | +Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bounded, retained filesystem tool results. ## Known Limitations and Deferred Work diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 754d6ee6f1..e43aa85f5a 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -31,9 +31,7 @@ Like every event they must sit inside an open turn. The mid-turn points (`PreToo ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | This library registers nothing. Its `hook/invoked` and `hook/result` events are log-only and do not enter derived messages; bridge packages decide whether parsed `additionalContext`, blocks, or continuation feedback reach the model. | Zero direct tokens. Persisted hook audit records add no context tokens. | +Indirectly, through `dsh-hooks-claude` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback. ## Known Limitations and Deferred Work diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 2a1eeb9e7c..8864897c84 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -12,9 +12,7 @@ Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: ` ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | This seam registers no prompt, schema, or message. A consumer may expose configured mode, enforcement, denial, or `SANDBOX_UNAVAILABLE` facts in its own guidance or result. | Zero direct tokens. Confinement changes model-visible text only through the consuming capability. | +Indirectly, through consumers such as `dsh-bash-sandbox`, which may expose enforcement, denial, or sandbox-unavailable facts in schemas or results. ## Known Limitations and Deferred Work diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 39219396d6..5d25d1cb5d 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -37,9 +37,7 @@ The registry does not render model guidance or register model-facing tools. [`@d ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | The registry renders nothing and registers no tool. `dsh-tool-skill` turns `list()` summaries into a session prefix and a selected `get()` body into a tool result; provider failures can remove entries from that request's catalog. | Zero direct tokens. Catalog size, descriptions, and loaded body length affect context only through the consumer. | +Indirectly, through `dsh-tool-skill`, which renders provider summaries into the session prefix and loaded instructions into retained tool results. ## Known Limitations and Deferred Work diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index b09df7b2aa..a3c0905422 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -41,9 +41,7 @@ A per-run isolated config directory for an external CLI child (the target of `CL ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | This process utility registers no provider, prompt, tool, or message. A consuming backend's child application decides the child's model context; environment scrubbing and isolated config directories prevent ambient credentials and user state from silently changing that composition. | Zero direct tokens. It can indirectly stabilize child context, but it adds no text to parent or child requests. | +Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories. ## Known Limitations and Deferred Work diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 0bb0df7285..c85a6a4566 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -62,9 +62,7 @@ The current model-facing tool collects synchronously: it awaits the child result ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | The provider registry registers no prompt or tool. Provider lifecycle makes a bound `dsh-tool-subagent` schema appear or disappear, and `inheritsParentContext` selects truthful fresh-versus-fork wording. Run events are observe-only. | Zero direct tokens. Child prompts and final results enter model contexts only through a provider and consumer. | +Indirectly, through `dsh-tool-subagent` and registered providers, which expose delegation schemas, child contexts, and retained parent results. ## Known Limitations and Deferred Work diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 6aef2f3a4d..b9f51bb2b9 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -43,9 +43,7 @@ Constraints: `suite.ts` imports vitest, so the package is importable only inside ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None in production | The test harness records, normalizes, scrubs, and compares request headers and ACP transcripts but does not alter the agent's assembled context. Replay scenarios obtain assistant chunks from `dsh-llm-replay`; record mode uses the real composition. | Zero production tokens. Replay spends no provider tokens, while record mode pays the composition's ordinary model cost; golden scrubbing changes files only, never the live request. | +None, as this test-only harness records, normalizes, and compares ACP transcripts without changing the agent's assembled model request. ## Known Limitations and Deferred Work diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index b0b9e29668..7a4f9fc263 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -53,9 +53,7 @@ A seeded or forked session arrives with events already in its log because constr ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None | The plugin observes and validates session events, agent states, and frozen model requests; it does not rewrite a prompt, schema, message, or stream. An invariant failure aborts the faulty turn instead of adding guidance. | Zero model tokens when checks pass; a failure prevents or ends a request rather than contributing context. | +None, as this observer only validates events and frozen requests and never rewrites prompts, schemas, messages, or streams. ## Known Limitations and Deferred Work diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index b9fc41c130..fdea712274 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -16,9 +16,7 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the co ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | Boot and environment helpers load the configured plugin tree but register no prompt, schema, or message of their own. `.env`, loader diagnostics, and config-path selection are process concerns, not model context. | Zero direct tokens. The selected configuration indirectly determines which other packages contribute context. | +Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application. ## Known Limitations and Deferred Work diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index 378e7a626f..b4a93ce5bf 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -25,9 +25,7 @@ This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh- ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | This UI-neutral seam registers no prompt or tool. A consumer such as `dsh-tool-ask-user` turns a model call into an `ask()` request and converts the provider's human answer into a model-visible tool result. | Zero direct tokens. Question and answer size affect context only through the consumer. | +Indirectly, through consumers such as `dsh-tool-ask-user`, which return human answers as retained tool-result tokens. ## Known Limitations and Deferred Work diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md index 5c7fa07272..47c349ffd5 100644 --- a/packages/util/brand/README.md +++ b/packages/util/brand/README.md @@ -27,6 +27,4 @@ This package owns ONLY the primitive — no concrete id, no runtime code beyond ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None | `Branded` is erased at compile time and registers no runtime plugin, prompt, schema, event, or message. Branded ids serialize exactly as their underlying strings when another package logs or renders them. | Zero direct or indirect token overhead beyond the string another package already chose to expose. | +None, as `Branded` is erased at compile time and registers no runtime behavior. diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index 86c76ca5b4..ff56b65535 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -43,9 +43,7 @@ Local file `read`/`write`/`edit` take no `timeoutMs`: a syscall is best-effort-a ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | This library only creates and classifies abort signals. It registers no prompt, schema, or message; consumers decide whether a timeout becomes a marker, a structured error, or no model-visible change. | Zero direct tokens. It can indirectly cap or replace a consumer's result when that consumer renders a timeout. | +Indirectly, through consumers such as `dsh-timeout-policy`, which may replace a provider result with a retained timeout error or suppress a late result. ## Known Limitations and Deferred Work diff --git a/packages/web/web/README.md b/packages/web/web/README.md index 3582ca9d1d..514a6aa51d 100644 --- a/packages/web/web/README.md +++ b/packages/web/web/README.md @@ -45,9 +45,7 @@ The failure branches throw `WebError`, whose structured code (plus message detai ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | The seam registers providers, not tools or prompt text. `dsh-tool-web` renders normalized search answers, sources, fetched bodies, and structured `WebError` values. Provider selection details stay internal except for an execution error. | Zero direct tokens. The seam indirectly bounds search result tokens by truncating sources to `maxResults`; all rendered size comes through a consumer. | +Indirectly, through `dsh-tool-web`, which renders normalized search or fetch data and errors as bounded, retained tool results. ## Known Limitations and Deferred Work diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 1c02267ed4..397822108e 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -40,9 +40,7 @@ A child that resolves normally with a non-completed stop reason is not an infras ## Model Experience -| Context surface | What the model sees | Token effect | -|---|---|---| -| None directly | The service seam and `workflow/*` observer events register no prompt, schema, or message. `dsh-tool-workflow` renders the parent-facing contract and final value; an engine decides which child prompts run. | Zero direct tokens. Parent result and child contexts affect tokens only through the consumer and implementation. | +Indirectly, through `dsh-tool-workflow` and a workflow engine, which create child-agent requests and return a retained parent tool result. ## Known Limitations and Deferred Work diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 33f383b4f1..2d517c9368 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -1,6 +1,8 @@ /** * Doc-sync gate: require every workspace package README to explain its exact - * model-visible context surface and token behavior in the canonical table. + * model-visible context surface and token behavior. Most packages require the + * canonical table; an audited allowlist requires one concise zero-effect or + * indirect-only sentence instead. * * Run: `tsx scripts/verify-package-readme-model-experience.ts`. */ @@ -15,6 +17,37 @@ const TABLE_HEADER = '| Context surface | What the model sees | Token effect |' const TABLE_DIVIDER = '|---|---|---|' const H2_HEADING = /^## .+$/ +type SentenceKind = 'none' | 'indirect' + +interface SentenceContract { + kind: SentenceKind + reason: string +} + +/** + * Packages whose Model Experience is simple enough for one gated sentence. + * Every other package must carry the canonical table. A package moves on or + * off this list in the same change that changes its context behavior. + */ +const SENTENCE_MODEL_EXPERIENCE: Readonly> = { + 'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, + 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, + 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, + 'packages/sandbox/sandbox': { kind: 'indirect', reason: 'Only sandbox-consuming capabilities render enforcement facts.' }, + 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, + 'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-subagent.' }, + 'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' }, + 'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' }, + 'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, + 'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, + 'packages/ui/user-interaction': { kind: 'indirect', reason: 'Only a model-facing consumer renders human answers.' }, + 'packages/util/brand': { kind: 'none', reason: 'The type-only primitive is erased at compile time.' }, + 'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' }, + 'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' }, + 'packages/workflow/workflow': { kind: 'indirect', reason: 'The service delegates parent and child model rendering to its consumer and engine.' }, +} + interface Failure { path: string message: string @@ -47,8 +80,22 @@ function proseLines(text: string): Line[] { const failures: Failure[] = [] const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort() +const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length))) +let tableCount = 0 +let noneCount = 0 +let indirectCount = 0 + +for (const [pkg, contract] of Object.entries(SENTENCE_MODEL_EXPERIENCE)) { + if (!scannedPackages.has(pkg)) { + failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry does not name a scanned package' }) + } + if (contract.reason.trim().length === 0) { + failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry must justify why a table is unnecessary' }) + } +} for (const packageJson of packageJsons) { + const pkg = packageJson.slice(0, -'/package.json'.length) const readme = packageJson.replace(/package\.json$/, 'README.md') const abs = resolve(root, readme) if (!existsSync(abs)) { @@ -86,10 +133,30 @@ for (const packageJson of packageJsons) { const body = lines.slice(lines.indexOf(modelHeading) + 1) const nextH2 = body.findIndex(line => H2_HEADING.test(line.raw)) const section = nextH2 < 0 ? body : body.slice(0, nextH2) + const content = section.filter(line => line.raw.trim().length > 0) + const sentenceContract = SENTENCE_MODEL_EXPERIENCE[pkg] + if (sentenceContract !== undefined) { + const pattern = sentenceContract.kind === 'none' ? /^None, as .+\.$/ : /^Indirectly, through .+\.$/ + if (content.length !== 1 || !pattern.test(content[0]?.raw ?? '')) { + const prefix = sentenceContract.kind === 'none' ? 'None, as ' : 'Indirectly, through ' + failures.push({ path: readme, message: `must contain exactly one sentence beginning ${JSON.stringify(prefix)} and ending with a period` }) + continue + } + if (sentenceContract.kind === 'none') noneCount += 1 + else indirectCount += 1 + continue + } + + const shortSentence = content.find(line => /^None, as |^Indirectly, through /.test(line.raw)) + if (shortSentence !== undefined) { + failures.push({ path: readme, message: `line ${shortSentence.index}: short Model Experience form requires an audited entry in SENTENCE_MODEL_EXPERIENCE` }) + continue + } + const headers = section.filter(line => line.raw === TABLE_HEADER) const header = headers[0] const headerIndex = header === undefined ? -1 : section.indexOf(header) - if (headers.length !== 1 || headerIndex < 0 || section[headerIndex + 1]?.raw !== TABLE_DIVIDER) { + if (header === undefined || headers.length !== 1 || headerIndex < 0 || section[headerIndex + 1]?.raw !== TABLE_DIVIDER) { failures.push({ path: readme, message: `must contain the exact table header ${TABLE_HEADER}` }) continue } @@ -109,10 +176,17 @@ for (const packageJson of packageJsons) { failures.push({ path: readme, message: `line ${row.index}: invalid three-column Model Experience row: ${row.raw}` }) } } + const tableLines = new Set([header, section[headerIndex + 1], ...rows]) + const extra = content.find(line => !tableLines.has(line)) + if (extra !== undefined) { + failures.push({ path: readme, message: `line ${extra.index}: Model Experience table section contains non-table content: ${extra.raw}` }) + continue + } + tableCount += 1 } if (failures.length === 0) { - console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) carry the canonical ${HEADING} table.`) + console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${tableCount} tables, ${noneCount} none, ${indirectCount} indirect), all conform.`) process.exit(0) }