Merge remote-tracking branch 'origin/codex/invariant-service-seam' into codex/invariant-package-registration-gate
# Conflicts: # .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml # docs/core-data-structures/session.md # docs/module-graph.md # package.json # packages/sdk/scripts/tsconfig.json
This commit is contained in:
78
scripts/agent-note-tree.ts
Normal file
78
scripts/agent-note-tree.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Shared structural source of truth for the Agent Note tree. Lifecycle and class
|
||||
* sets are closed under `.agents/notes/README.md`; importing this module is pure.
|
||||
*/
|
||||
|
||||
import { globSync, readdirSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
|
||||
export const agentNoteRoot = resolve(import.meta.dirname, '../.agents/notes')
|
||||
|
||||
/** The closed set of Agent Note lifecycles (top-level folders under .agents/notes/). */
|
||||
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
|
||||
|
||||
/**
|
||||
* The closed set of Agent Note classes (nested folder under each lifecycle). Adding a
|
||||
* class is a deliberate act: extend this list AND the README's Classification
|
||||
* section. The gate rejects any folder not listed here.
|
||||
*/
|
||||
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
|
||||
|
||||
/** Non-Agent Note Markdown allowed to sit directly at a lifecycle root. */
|
||||
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
|
||||
|
||||
/** One Agent Note file, as discovered by the walker. */
|
||||
export interface AgentNote {
|
||||
lifecycle: string
|
||||
/** Path relative to .agents/notes. */
|
||||
rel: string
|
||||
/** `yyyy-mm-dd` from the filename. */
|
||||
date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the Agent Note tree, enforcing the structure rules. Returns every valid Agent Note
|
||||
* plus one error string per violation (unknown lifecycle or class folder, bad
|
||||
* depth, or bad filename). Callers treat a non-empty error list as fatal.
|
||||
*/
|
||||
export function walkAgentNoteTree(): { notes: AgentNote[]; errors: string[] } {
|
||||
const notes: AgentNote[] = []
|
||||
const errors: string[] = []
|
||||
// The lifecycle set is closed too: any directory under .agents/notes/ that is not
|
||||
// a known lifecycle would otherwise hold Agent Notes invisible to the walk below.
|
||||
for (const entry of readdirSync(agentNoteRoot, { withFileTypes: true })) {
|
||||
if (entry.name === 'INDEX.md') {
|
||||
errors.push('structure: INDEX.md — centralized Agent Note indexes are forbidden; browse the lifecycle/class tree or search the repository')
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
|
||||
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
|
||||
}
|
||||
}
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: agentNoteRoot }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const segs = match.split('/')
|
||||
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
|
||||
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
|
||||
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME Agent Note,
|
||||
// indexed via its English filename; the pairing gate owns its consistency.
|
||||
if (match.endsWith('.zh.md')) continue
|
||||
const cls = segs[1]
|
||||
const base = segs[2]
|
||||
if (segs.length !== 3 || cls === undefined || base === undefined) {
|
||||
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
|
||||
continue
|
||||
}
|
||||
if (!(CLASSES as readonly string[]).includes(cls)) {
|
||||
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
|
||||
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
|
||||
continue
|
||||
}
|
||||
notes.push({ lifecycle, rel: match, date: base.slice(0, 10) })
|
||||
}
|
||||
}
|
||||
return { notes, errors }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Build the SDK runtime executables and Python node carrier. The fixed
|
||||
* `@yao-pkg/pkg --sea` route, deploy flags, and artifact layout are owned by
|
||||
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
|
||||
* .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
|
||||
* The staged closure is symlink-free, and whole-tree assets cover Cordis's
|
||||
* runtime imports that pkg cannot discover statically.
|
||||
*/
|
||||
@@ -69,7 +69,7 @@ class Target {
|
||||
readonly nodeRange: string,
|
||||
/**
|
||||
* pkg platform tag. Windows is a documented non-goal
|
||||
* (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
* (.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
*/
|
||||
readonly platform: Platform,
|
||||
/** pkg CPU tag. */
|
||||
@@ -190,7 +190,7 @@ class BuildCli {
|
||||
' --dry-run print every command and config patch without executing.',
|
||||
' --help print this help.',
|
||||
'',
|
||||
`Build route: ${PKG_SPEC} --sea; see docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`,
|
||||
`Build route: ${PKG_SPEC} --sea; see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`,
|
||||
`Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
26
scripts/check-expected-filenames.sh
Executable file
26
scripts/check-expected-filenames.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Vendored upstream paths follow vendor/README.md instead of repository naming policy.
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
candidate_file=$(mktemp)
|
||||
trap 'unlink "$candidate_file"' EXIT
|
||||
git -C "$root" ls-files -z -- \
|
||||
':(icase,glob)*golden*' \
|
||||
':(icase,glob)**/*golden*' \
|
||||
':(exclude,glob)vendor/**' > "$candidate_file"
|
||||
|
||||
violations=()
|
||||
while IFS= read -r -d '' path; do
|
||||
violations+=("$path")
|
||||
done < "$candidate_file"
|
||||
|
||||
if (( ${#violations[@]} == 0 )); then
|
||||
echo 'check-expected-filenames: no tracked non-vendor filename contains "golden".'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo 'check-expected-filenames: tracked non-vendor filenames must not contain "golden":' >&2
|
||||
printf ' %s\n' "${violations[@]}" >&2
|
||||
echo 'Rename each file with an accurate term such as "expected".' >&2
|
||||
exit 1
|
||||
@@ -188,7 +188,7 @@ function remapBlockPaths(output: string, blocks: Block[]): string {
|
||||
})
|
||||
}
|
||||
|
||||
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
|
||||
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
|
||||
|
||||
const files: string[] = []
|
||||
for (const pattern of markdownGlobs) {
|
||||
|
||||
@@ -837,7 +837,7 @@ export function render(entries: CatalogEntry[]): string {
|
||||
'',
|
||||
'## Seam packages (not directly loadable)',
|
||||
'',
|
||||
'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)).',
|
||||
'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).',
|
||||
'',
|
||||
...entries.filter(e => e.kind === 'seam').map(e => renderTerse(e, ` — abstract \`${e.className ?? ''}\``)),
|
||||
'',
|
||||
|
||||
@@ -984,14 +984,14 @@ function renderSnapshotReplay(): string {
|
||||
' participant Workspace',
|
||||
' participant Replay as llm-replay adapter',
|
||||
' participant ACP as acp-agent subprocess',
|
||||
' participant Golden as stdout golden',
|
||||
' participant Expected as stdout expected output',
|
||||
' Recorder->>Fixture: session.jsonl + workspace inputs',
|
||||
' Fixture->>Workspace: seed files and hook configs',
|
||||
' Fixture->>Replay: recorded StreamChunk script',
|
||||
` Replay->>ACP: deterministic ${mermaidCode('llm/stream')} chunks`,
|
||||
' ACP->>Workspace: bash, fs, and hook side effects',
|
||||
' ACP->>Golden: normalized sessionUpdate stream',
|
||||
' Golden-->>ACP: diff must be empty',
|
||||
' ACP->>Expected: normalized sessionUpdate stream',
|
||||
' Expected-->>ACP: diff must be empty',
|
||||
'```',
|
||||
'',
|
||||
'The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.',
|
||||
@@ -1054,7 +1054,7 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
...generatedHeader('Documentation Graph Index'),
|
||||
'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog.md](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md).',
|
||||
'',
|
||||
'The process decision behind this index is recorded in [the documentation graph RFC](rfc/implemented/process/2026-07-03-documentation-graph-atlas.md).',
|
||||
'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md).',
|
||||
'',
|
||||
'| Graph | Mode |',
|
||||
'| --- | --- |',
|
||||
|
||||
@@ -349,7 +349,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
|
||||
'',
|
||||
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'',
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Regenerate `docs/rfc/INDEX.md` — the fully generated RFC index — from the
|
||||
* RFC tree (see [rfc-index.ts](./rfc-index.ts) for the layout contract and
|
||||
* rendering rules). The whole file is generated state; the curated prose lives
|
||||
* in `docs/rfc/README.md`. Freshness is asserted by
|
||||
* `verify-rfc-classification.ts` (a `doc-sync` member), so a stale committed
|
||||
* index fails CI.
|
||||
*
|
||||
* Run: `pnpm run gen-rfc-index`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
|
||||
|
||||
const { rfcs, errors } = walkRfcTree()
|
||||
if (errors.length > 0) {
|
||||
console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const indexPath = resolve(rfcRoot, 'INDEX.md')
|
||||
const next = renderIndex(rfcs)
|
||||
let current: string | undefined
|
||||
try {
|
||||
current = readFileSync(indexPath, 'utf8')
|
||||
} catch {
|
||||
// Missing INDEX.md is the fresh-generation case, not an error: fall through and write it.
|
||||
}
|
||||
if (next === current) {
|
||||
console.log(`gen-rfc-index: docs/rfc/INDEX.md is up to date (${rfcs.length} RFCs).`)
|
||||
} else {
|
||||
writeFileSync(indexPath, next)
|
||||
console.log(`gen-rfc-index: docs/rfc/INDEX.md regenerated (${rfcs.length} RFCs).`)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* plugin. Runtime registration is the source of truth for computed schemas;
|
||||
* the manifest is checked against every on-disk `tool-*` package. `--check`
|
||||
* verifies the committed artifact. Rationale and ownership live in
|
||||
* `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`.
|
||||
* `.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md`.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
@@ -119,7 +119,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
toolsConfig: { mode: 'code' },
|
||||
async mount() {},
|
||||
note:
|
||||
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
|
||||
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-bash',
|
||||
@@ -144,7 +144,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolCordis)
|
||||
},
|
||||
note:
|
||||
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes.',
|
||||
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs',
|
||||
@@ -367,7 +367,7 @@ export function render(catalog: ToolCatalog): string {
|
||||
'',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.',
|
||||
'',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog Agent Note](../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'',
|
||||
'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.',
|
||||
'',
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Shared source of truth for the RFC index: the tree walker (structure rules) and the README
|
||||
* table renderer. `gen-rfc-index.ts` writes the generated regions;
|
||||
* `verify-rfc-classification.ts` checks structure and asserts the committed regions are fresh.
|
||||
* Lifecycle and class sets are closed under `docs/rfc/README.md`; rows derive
|
||||
* from path, H1, and filename date and sort deterministically. Import is pure.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { globSync } from 'node:fs'
|
||||
|
||||
export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc')
|
||||
|
||||
/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
|
||||
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
|
||||
|
||||
/**
|
||||
* The closed set of RFC classes (nested folder under each lifecycle). Adding a
|
||||
* class is a deliberate act: extend this list AND the README's Classification
|
||||
* section. The gate rejects any folder not listed here.
|
||||
*/
|
||||
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
|
||||
|
||||
/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
|
||||
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
|
||||
|
||||
/** Title-case a class/lifecycle folder name for a README heading. */
|
||||
const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
|
||||
|
||||
/** One RFC file, as discovered by the walker. */
|
||||
export interface Rfc {
|
||||
lifecycle: string
|
||||
cls: string
|
||||
base: string
|
||||
/** Path relative to docs/rfc — the README link target. */
|
||||
rel: string
|
||||
/** H1 text with any `RFC: ` prefix stripped — the README row title. */
|
||||
title: string
|
||||
/** `yyyy-mm-dd` from the filename — the "First proposed" column. */
|
||||
date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the RFC tree, enforcing the structure rules. Returns every valid RFC
|
||||
* plus one error string per violation (unknown lifecycle or class folder, bad
|
||||
* depth, bad filename, missing/malformed H1). Callers treat a non-empty error
|
||||
* list as fatal — the index is only generated from a structurally valid tree.
|
||||
*/
|
||||
export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
|
||||
const rfcs: Rfc[] = []
|
||||
const errors: string[] = []
|
||||
// The lifecycle set is closed too: any directory under docs/rfc/ that is not
|
||||
// a known lifecycle would otherwise hold RFCs invisible to the walk below.
|
||||
for (const entry of readdirSync(rfcRoot, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
|
||||
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
|
||||
}
|
||||
}
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const segs = match.split('/')
|
||||
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
|
||||
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
|
||||
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
|
||||
// indexed via its English filename; the pairing gate owns its consistency.
|
||||
if (match.endsWith('.zh.md')) continue
|
||||
const cls = segs[1]
|
||||
const base = segs[2]
|
||||
if (segs.length !== 3 || cls === undefined || base === undefined) {
|
||||
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
|
||||
continue
|
||||
}
|
||||
if (!(CLASSES as readonly string[]).includes(cls)) {
|
||||
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
|
||||
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
|
||||
continue
|
||||
}
|
||||
const firstLine = readFileSync(resolve(rfcRoot, match), 'utf8').split('\n', 1)[0] ?? ''
|
||||
const h1 = /^#\s+(?:RFC:\s+)?(.+?)\s*$/.exec(firstLine)
|
||||
if (!h1?.[1]) {
|
||||
errors.push(`title: ${match} — first line must be an H1 (\`# RFC: <title>\` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`)
|
||||
continue
|
||||
}
|
||||
rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) })
|
||||
}
|
||||
}
|
||||
return { rfcs, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one lifecycle's section body: a `### {Class}` heading plus a
|
||||
* `| Title | First proposed |` table for every non-empty class, in CLASSES
|
||||
* order, rows sorted by date then filename.
|
||||
*/
|
||||
function renderLifecycle(rfcs: Rfc[], lifecycle: string): string {
|
||||
const sections: string[] = []
|
||||
for (const cls of CLASSES) {
|
||||
const rows = rfcs
|
||||
.filter(r => r.lifecycle === lifecycle && r.cls === cls)
|
||||
.sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base))
|
||||
if (rows.length === 0) continue
|
||||
const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n')
|
||||
sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`)
|
||||
}
|
||||
return sections.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete `docs/rfc/INDEX.md` content: a generated-file banner
|
||||
* followed by one `## {Lifecycle}` section per lifecycle in canonical order.
|
||||
* The whole file is generated state — there is no curated region to preserve.
|
||||
*/
|
||||
export function renderIndex(rfcs: Rfc[]): string {
|
||||
const parts = [
|
||||
'# RFC index',
|
||||
'',
|
||||
'Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; `verify-rfc-classification` fails when this file is stale. The curated front door — layout, classification, when to write one, and the in-file format — is [README.md](README.md).',
|
||||
]
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
parts.push('', `## ${heading(lifecycle)}`, '', renderLifecycle(rfcs, lifecycle))
|
||||
}
|
||||
return `${parts.join('\n')}\n`
|
||||
}
|
||||
|
||||
/** Matches an index-shaped table row (a `| [title](lifecycle/…) |` line) — generated state that must not appear in curated prose. */
|
||||
export const INDEX_ROW = /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//
|
||||
@@ -347,8 +347,8 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
|
||||
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }),
|
||||
pnpmScript('mermaid', 'verify-mermaid'),
|
||||
pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }),
|
||||
pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }),
|
||||
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }),
|
||||
pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }),
|
||||
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
|
||||
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
||||
|
||||
@@ -628,7 +628,7 @@ def build_snapshot_files(
|
||||
child_ids: list[str],
|
||||
cwd: Path,
|
||||
) -> dict[str, str]:
|
||||
"""Render the SDK result and three persisted logs into stable goldens."""
|
||||
"""Render the SDK result and three persisted logs into stable expected outputs."""
|
||||
replacements = [(str(cwd), "{{cwd}}"), (SNAPSHOT_SESSION_ID, "{{parent}}")]
|
||||
for index, child_id in enumerate(child_ids, start=1):
|
||||
replacements.append((child_id, f"{{{{child-{index}}}}}"))
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
"docs/development.md",
|
||||
"docs/i18n/README.md",
|
||||
"docs/i18n/translation-rules.md",
|
||||
"docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
|
||||
"docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
|
||||
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
|
||||
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
|
||||
"python/README.md",
|
||||
"python/sdk-runtime/README.md",
|
||||
"python/sdk/README.md"
|
||||
],
|
||||
"excluded": [
|
||||
".agents/notes/AGENTS.md",
|
||||
".agents/notes/implemented/AGENTS.md",
|
||||
"docs/AGENTS.md",
|
||||
"docs/config-catalog.md",
|
||||
"docs/cordis-catalog/",
|
||||
|
||||
@@ -50,13 +50,13 @@ describe('date-based pairing frontier', () => {
|
||||
const cutoff = '2026-07-14'
|
||||
|
||||
it('enforces the cutoff day and every later day, but not the preceding day', () => {
|
||||
expect(requiresPairByDate('docs/rfc/2026-07-13-before.md', cutoff)).toBe(false)
|
||||
expect(requiresPairByDate('docs/rfc/2026-07-14-at-cutoff.md', cutoff)).toBe(true)
|
||||
expect(requiresPairByDate('docs/rfc/2026-07-15-after.md', cutoff)).toBe(true)
|
||||
expect(requiresPairByDate('.agents/notes/2026-07-13-before.md', cutoff)).toBe(false)
|
||||
expect(requiresPairByDate('.agents/notes/2026-07-14-at-cutoff.md', cutoff)).toBe(true)
|
||||
expect(requiresPairByDate('.agents/notes/2026-07-15-after.md', cutoff)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches only a date at the start of the basename', () => {
|
||||
expect(datedDocumentDate('docs/rfc/2026-07-14-proposal.md')).toBe('2026-07-14')
|
||||
expect(datedDocumentDate('.agents/notes/2026-07-14-proposal.md')).toBe('2026-07-14')
|
||||
expect(datedDocumentDate('docs/release-notes-2026-07-14-alpha.md')).toBeUndefined()
|
||||
expect(requiresPairByDate('docs/release-notes-2026-07-14-alpha.md', cutoff)).toBe(false)
|
||||
})
|
||||
|
||||
27
scripts/verify-agent-note-classification.ts
Normal file
27
scripts/verify-agent-note-classification.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Enforce Agent Note lifecycle/class paths and dated filenames. Structural rules
|
||||
* are shared with `agent-note-tree.ts`; the closed classification contract lives
|
||||
* in `.agents/notes/README.md`.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { walkAgentNoteTree } from './agent-note-tree.ts'
|
||||
|
||||
const { notes, errors } = walkAgentNoteTree()
|
||||
|
||||
// Keep the former homes unavailable so new notes cannot silently escape this tree.
|
||||
for (const legacyRoot of ['docs/rfc', 'docs/rfcs']) {
|
||||
if (existsSync(resolve(import.meta.dirname, '..', legacyRoot))) {
|
||||
errors.push(`legacy-path: ${legacyRoot}/ is forbidden — put Agent Notes under .agents/notes/`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-agent-note-classification: ${notes.length} Agent Note(s) checked, structure consistent.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-agent-note-classification: violations found:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
@@ -1,22 +1,22 @@
|
||||
/**
|
||||
* Enforce RFC headers, lifecycle-specific sections, alternatives, and retired
|
||||
* Enforce Agent Note headers, lifecycle-specific sections, alternatives, and retired
|
||||
* marker rules. Classification and filenames belong to the sibling tree gate;
|
||||
* translation structure belongs to the pairing gate. Exact format and
|
||||
* grandfathering rules live in `docs/rfc/README.md`.
|
||||
* grandfathering rules live in `.agents/notes/README.md`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { rfcRoot, walkRfcTree } from './rfc-index.ts'
|
||||
import { agentNoteRoot, walkAgentNoteTree } from './agent-note-tree.ts'
|
||||
|
||||
/** The date the format contract landed; the grandfather comment is valid only before it. */
|
||||
const FORMAT_ADOPTED = '2026-07-05'
|
||||
|
||||
/** The exact comment a pre-format RFC carries in place of `## Alternatives considered`. */
|
||||
const GRANDFATHER = '<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->'
|
||||
/** The exact comment a pre-format Agent Note carries in place of `## Alternatives considered`. */
|
||||
const GRANDFATHER = '<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->'
|
||||
|
||||
/** The retired debt marker that flagged pre-format bodies; banned so it cannot creep back. */
|
||||
const LEGACY_MARKER = 'XXX: legacy ADR/RFC body format'
|
||||
const LEGACY_MARKERS = ['XXX: legacy ADR/RFC body format', 'XXX: legacy ADR/Agent Note body format']
|
||||
|
||||
/** Status-line grammar per lifecycle folder. */
|
||||
const STATUS: Record<string, RegExp> = {
|
||||
@@ -35,13 +35,13 @@ const REQUIRED: Record<string, string[]> = {
|
||||
/** Headings banned in `implemented/` — proposal-era spec-speak per the slop checklist. */
|
||||
const BANNED_IMPLEMENTED = /^## (?:Proposal\b|Plan\b|Migration plan\b|Acceptance criteria\b)/i
|
||||
|
||||
const { rfcs, errors } = walkRfcTree()
|
||||
const { notes, errors } = walkAgentNoteTree()
|
||||
|
||||
for (const rfc of rfcs) {
|
||||
for (const note of notes) {
|
||||
const fail = (msg: string): void => {
|
||||
errors.push(`format: ${rfc.rel} — ${msg}`)
|
||||
errors.push(`format: ${note.rel} — ${msg}`)
|
||||
}
|
||||
const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n')
|
||||
const lines = readFileSync(resolve(agentNoteRoot, note.rel), 'utf8').split('\n')
|
||||
// Format tokens inside fenced examples are not document structure.
|
||||
let inFence = false
|
||||
const prose = lines.filter((l) => {
|
||||
@@ -52,11 +52,11 @@ for (const rfc of rfcs) {
|
||||
return !inFence
|
||||
})
|
||||
|
||||
if (!/^# RFC: \S/.test(lines[0] ?? '')) fail('line 1 must be `# RFC: <title>`')
|
||||
if (!/^# Agent Note: \S/.test(lines[0] ?? '')) fail('line 1 must be `# Agent Note: <title>`')
|
||||
if (lines[1] !== '') fail('line 2 must be blank')
|
||||
const status = STATUS[rfc.lifecycle]
|
||||
const status = STATUS[note.lifecycle]
|
||||
if (status !== undefined && !status.test(lines[2] ?? '')) {
|
||||
fail(`line 3 must match the ${rfc.lifecycle} status grammar (${String(status)})`)
|
||||
fail(`line 3 must match the ${note.lifecycle} status grammar (${String(status)})`)
|
||||
}
|
||||
if (lines[3] !== '') fail('line 4 must be blank')
|
||||
const statusLines = prose.filter(l => l.startsWith('Status:') && l !== lines[2])
|
||||
@@ -66,29 +66,29 @@ for (const rfc of rfcs) {
|
||||
|
||||
const h2s = prose.filter(l => l.startsWith('## ')).map(l => l.trimEnd())
|
||||
if (h2s[0] !== '## Problem') fail(`the first section must be \`## Problem\` (got ${JSON.stringify(h2s[0] ?? '<none>')})`)
|
||||
for (const required of REQUIRED[rfc.lifecycle] ?? []) {
|
||||
for (const required of REQUIRED[note.lifecycle] ?? []) {
|
||||
if (!h2s.includes(required)) fail(`missing the required \`${required}\` section`)
|
||||
}
|
||||
if (rfc.lifecycle === 'implemented') {
|
||||
if (note.lifecycle === 'implemented') {
|
||||
for (const h2 of h2s.filter(h => BANNED_IMPLEMENTED.test(h))) {
|
||||
fail(`\`${h2}\` is a proposal-era heading; an implemented RFC states what is (fold it into Decision/Consequences/Testing)`)
|
||||
fail(`\`${h2}\` is a proposal-era heading; an implemented Agent Note states what is (fold it into Decision/Consequences/Testing)`)
|
||||
}
|
||||
}
|
||||
|
||||
const hasSection = h2s.includes('## Alternatives considered')
|
||||
const hasGrandfather = prose.includes(GRANDFATHER)
|
||||
if (hasSection && hasGrandfather) fail('carries both `## Alternatives considered` and the grandfather comment — drop the comment')
|
||||
if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format RFC whose alternatives are not reconstructible carries the grandfather comment instead — see docs/rfc/README.md § The file format)')
|
||||
if (hasGrandfather && rfc.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for RFCs dated before ${FORMAT_ADOPTED}`)
|
||||
if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format Agent Note whose alternatives are not reconstructible carries the grandfather comment instead — see .agents/notes/README.md § The file format)')
|
||||
if (hasGrandfather && note.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for Agent Notes dated before ${FORMAT_ADOPTED}`)
|
||||
|
||||
if (prose.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker')
|
||||
if (prose.some(line => LEGACY_MARKERS.some(marker => line.includes(marker)))) fail('carries the retired legacy-format debt marker')
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-rfc-format: ${rfcs.length} RFC(s) checked, all conform to docs/rfc/README.md § The file format.`)
|
||||
console.log(`verify-agent-note-format: ${notes.length} Agent Note(s) checked, all conform to .agents/notes/README.md § The file format.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-rfc-format: violations found:')
|
||||
console.error('verify-agent-note-format: violations found:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Verify root-relative `docs/*.md` tokens in repo-authored TypeScript. The
|
||||
* textual scan requires the extension, checks matching string literals too,
|
||||
* and excludes built declarations and vendored source.
|
||||
* Verify root-relative documentation paths in repo-authored TypeScript. The
|
||||
* textual scan covers `docs/*.md` and `.agents/notes/*.md`, requires the
|
||||
* extension, checks matching string literals too, and excludes built
|
||||
* declarations and vendored source.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
@@ -18,9 +19,9 @@ const isExcluded = (p: string): boolean =>
|
||||
p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
|
||||
|
||||
/** Root-relative Markdown path token, excluding trailing prose. */
|
||||
const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
|
||||
const DOC_REF = /(?:\bdocs|\.agents\/notes)\/[A-Za-z0-9._/-]+\.md/g
|
||||
|
||||
/** Find every broken `docs/….md` reference in one TypeScript file. */
|
||||
/** Find every broken root-relative documentation reference in one TypeScript file. */
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
return findReferenceViolations(root, absPath, DOC_REF, ref => ref, ref => !existsSync(resolve(root, ref)))
|
||||
}
|
||||
@@ -30,11 +31,11 @@ const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)
|
||||
console.log(`verify-doc-refs: ${checked} file(s) checked, all documentation references resolve.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-doc-refs: broken docs/*.md references found in source comments (target does not exist):')
|
||||
console.error('verify-doc-refs: broken documentation references found in source comments (target does not exist):')
|
||||
for (const v of all) {
|
||||
console.error(` ${v.file}:${v.line} ${v.ref}`)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'.agents/notes/**/*.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
|
||||
@@ -13,15 +13,16 @@ import { uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Files to check: doc-typecheck's scope, prompt goldens, and the AGENTS.md pair. */
|
||||
/** Files to check: doc-typecheck's scope, system-prompt expected outputs, and the AGENTS.md pair. */
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'.agents/notes/**/*.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
'examples/**/system-prompt.golden.md',
|
||||
'packages/**/system-prompt.golden.md',
|
||||
'examples/**/system-prompt.expected.md',
|
||||
'packages/**/system-prompt.expected.md',
|
||||
'AGENTS.md',
|
||||
'packages/AGENTS.md',
|
||||
]
|
||||
|
||||
@@ -17,6 +17,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'.agents/notes/**/*.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
|
||||
@@ -14,6 +14,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
/** Markdown + repo-authored TypeScript that may cite package paths. */
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'.agents/notes/**/*.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Doc-sync gate for the canonical package-README limitations section. It scans
|
||||
* package manifests, rejects missing or variant sections, and requires one
|
||||
* top-level bullet; audited packages in {@link NO_LIMITATIONS} must omit it.
|
||||
* See the [limitations RFC](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md).
|
||||
* See the [limitations Agent Note](../.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md).
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Doc-sync gate for package README Model Experience sections. It validates
|
||||
* audited package classifications, context-surface fields, package-owned text
|
||||
* blocks, generated-catalog links, and final-section order. See the
|
||||
* [Model Experience RFC](../docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md).
|
||||
* audited package classifications, model/token/KV-cache fields, package-owned
|
||||
* text blocks, generated-catalog links, and final-section order. See the
|
||||
* [Model Experience Agent Note](../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md).
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
@@ -12,8 +12,10 @@ import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const HEADING = '## Model Experience'
|
||||
const LIMITATIONS_HEADING = '## Known Limitations and Deferred Work'
|
||||
const MODEL_VIEW_LABEL = '**What the model sees**'
|
||||
const TOKEN_EFFECT_LABEL = '**Token effect**'
|
||||
const MODEL_VIEW_HEADING = '#### What the model sees'
|
||||
const TOKEN_EFFECT_HEADING = '#### Token effect'
|
||||
const KV_CACHE_EFFECT_HEADING = '#### KV Cache effect'
|
||||
const FIELD_HEADINGS = [MODEL_VIEW_HEADING, TOKEN_EFFECT_HEADING, KV_CACHE_EFFECT_HEADING] as const
|
||||
|
||||
type SentenceKind = 'none' | 'indirect'
|
||||
|
||||
@@ -34,9 +36,9 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Packages whose Model Experience is simple enough for one gated sentence.
|
||||
* Every other package must carry canonical context-surface blocks. A package
|
||||
* moves on or off this list with the change to its context behavior.
|
||||
* Packages whose Model Experience is simple enough for one gated sentence plus
|
||||
* a KV-cache field. Every other package must carry canonical context-surface
|
||||
* blocks. A package moves on or off this list with its context behavior.
|
||||
*/
|
||||
const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
|
||||
@@ -53,6 +55,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
|
||||
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
|
||||
@@ -91,35 +94,41 @@ interface ContextSurface {
|
||||
heading: Line
|
||||
modelView: Line
|
||||
tokenEffect: Line
|
||||
kvCacheEffect: Line
|
||||
title: string
|
||||
modelViewVerbatimBlocks: number
|
||||
verbatimBlocks: number
|
||||
}
|
||||
|
||||
/** Validate H4-plus-markdown literals nested after one context surface's fields. */
|
||||
function validateNestedVerbatim(raw: readonly string[]): { blocks: number; error?: string } {
|
||||
interface ParsedField {
|
||||
value: Line
|
||||
verbatimBlocks: number
|
||||
}
|
||||
|
||||
/** Validate H5-plus-markdown literals nested under one Model Experience field. */
|
||||
function validateNestedVerbatim(raw: readonly string[], fragments: Set<string>): { blocks: number; error?: string } {
|
||||
let cursor = 0
|
||||
while (raw[cursor]?.trim().length === 0) cursor += 1
|
||||
if (cursor === raw.length) return { blocks: 0 }
|
||||
|
||||
let blocks = 0
|
||||
const fragments = new Set<string>()
|
||||
while (true) {
|
||||
while (raw[cursor]?.trim().length === 0) cursor += 1
|
||||
if (cursor === raw.length) break
|
||||
if (!/^#### \S/.test(raw[cursor] ?? '')) {
|
||||
return { blocks, error: 'content after Token effect must be a titled H4 verbatim block' }
|
||||
if (!/^##### \S/.test(raw[cursor] ?? '')) {
|
||||
return { blocks, error: 'content after a field paragraph must be a titled H5 verbatim block' }
|
||||
}
|
||||
const title = (raw[cursor] as string).slice('#### '.length)
|
||||
const title = (raw[cursor] as string).slice('##### '.length)
|
||||
const fragment = headingFragment(title)
|
||||
if (fragment.length === 0) return { blocks, error: 'verbatim H4 title must be non-empty' }
|
||||
if (fragment.length === 0) return { blocks, error: 'verbatim H5 title must be non-empty' }
|
||||
if (fragments.has(fragment)) {
|
||||
return { blocks, error: `verbatim H4 title ${JSON.stringify(title)} is duplicated within its context surface` }
|
||||
return { blocks, error: `verbatim H5 title ${JSON.stringify(title)} is duplicated within its context surface` }
|
||||
}
|
||||
fragments.add(fragment)
|
||||
cursor += 1
|
||||
while (raw[cursor]?.trim().length === 0) cursor += 1
|
||||
if (raw[cursor] !== '```markdown') {
|
||||
return { blocks, error: 'each nested verbatim H4 requires an exact ```markdown fence' }
|
||||
return { blocks, error: 'each nested verbatim H5 requires an exact ```markdown fence' }
|
||||
}
|
||||
cursor += 1
|
||||
const contentStart = cursor
|
||||
@@ -132,7 +141,7 @@ function validateNestedVerbatim(raw: readonly string[]): { blocks: number; error
|
||||
return { blocks }
|
||||
}
|
||||
|
||||
/** GitHub-style fragment for the simple ASCII H4 titles allowed by this contract. */
|
||||
/** GitHub-style fragment for the simple ASCII nested titles allowed by this contract. */
|
||||
function headingFragment(title: string): string {
|
||||
return title.toLowerCase().replaceAll('`', '').replaceAll(/[^a-z0-9 _-]/g, '').trim().replaceAll(/\s+/g, '-')
|
||||
}
|
||||
@@ -165,6 +174,7 @@ let indirectCount = 0
|
||||
let verbatimBlockCount = 0
|
||||
let systemPromptSurfaceCount = 0
|
||||
let toolSchemaSurfaceCount = 0
|
||||
let kvCacheEffectCount = 0
|
||||
|
||||
for (const [pkg, reason] of Object.entries(NO_MODEL_EXPERIENCE_SECTION)) {
|
||||
if (!scannedPackages.has(pkg)) {
|
||||
@@ -258,13 +268,31 @@ for (const packageJson of packageJsons) {
|
||||
if (sentenceContract !== undefined) {
|
||||
const pattern = sentenceContract.kind === 'none' ? /^None, as .+\.$/ : /^Indirectly, through .+\.$/
|
||||
const rawContent = rawSection.filter(line => line.trim().length > 0)
|
||||
if (content.length !== 1 || rawContent.length !== 1 || !pattern.test(content[0]?.raw ?? '')) {
|
||||
const sentence = content[0]
|
||||
const kvCacheHeading = content[1]
|
||||
const kvCacheEffect = content[2]
|
||||
if (content.length !== 3 || rawContent.length !== 3 || !pattern.test(sentence?.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` })
|
||||
failures.push({ path: readme, message: `must contain exactly one sentence beginning ${JSON.stringify(prefix)} and ending with a period, followed by ${KV_CACHE_EFFECT_HEADING} and one non-empty paragraph` })
|
||||
continue
|
||||
}
|
||||
if (kvCacheHeading?.raw !== KV_CACHE_EFFECT_HEADING
|
||||
|| kvCacheEffect === undefined
|
||||
|| /^#{1,6} /.test(kvCacheEffect.raw)
|
||||
|| kvCacheEffect.raw.trim().length === 0) {
|
||||
failures.push({ path: readme, message: `line ${kvCacheHeading?.index ?? sentence?.index ?? modelHeading.index}: short Model Experience form requires exact ${KV_CACHE_EFFECT_HEADING} and one non-empty paragraph` })
|
||||
continue
|
||||
}
|
||||
if (sentence === undefined
|
||||
|| sentence.index !== modelHeading.index + 2
|
||||
|| kvCacheHeading.index !== sentence.index + 2
|
||||
|| kvCacheEffect.index !== kvCacheHeading.index + 2) {
|
||||
failures.push({ path: readme, message: 'short Model Experience sentence, KV-cache H4, and paragraph require one blank line between each element' })
|
||||
continue
|
||||
}
|
||||
if (sentenceContract.kind === 'none') explainedNoneCount += 1
|
||||
else indirectCount += 1
|
||||
kvCacheEffectCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -290,8 +318,6 @@ for (const packageJson of packageJsons) {
|
||||
const end = surfaceStarts[surfaceIndex + 1]?.index ?? content.length
|
||||
const entries = content.slice(start.index, end)
|
||||
const heading = entries[0] as Line
|
||||
const modelView = entries[1]
|
||||
const tokenEffect = entries[2]
|
||||
const title = heading.raw.slice('### '.length)
|
||||
const fragment = headingFragment(title)
|
||||
if (fragment.length === 0) {
|
||||
@@ -304,56 +330,100 @@ for (const packageJson of packageJsons) {
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (modelView === undefined || !modelView.raw.startsWith(`${MODEL_VIEW_LABEL}: `) || modelView.raw.slice(`${MODEL_VIEW_LABEL}: `.length).trim().length === 0) {
|
||||
failures.push({ path: readme, message: `line ${modelView?.index ?? heading.index}: context surface requires non-empty ${MODEL_VIEW_LABEL}: text` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (tokenEffect === undefined || !tokenEffect.raw.startsWith(`${TOKEN_EFFECT_LABEL}: `) || tokenEffect.raw.slice(`${TOKEN_EFFECT_LABEL}: `.length).trim().length === 0) {
|
||||
failures.push({ path: readme, message: `line ${tokenEffect?.index ?? heading.index}: context surface requires non-empty ${TOKEN_EFFECT_LABEL}: text` })
|
||||
const fieldStarts = entries
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(entry => /^#### \S/.test(entry.line.raw))
|
||||
if (fieldStarts.length !== FIELD_HEADINGS.length || fieldStarts[0]?.index !== 1) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: context surface requires exactly three ordered H4 fields: ${FIELD_HEADINGS.join(', ')}` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if ((surfaceIndex === 0 && heading.index !== modelHeading.index + 2)
|
||||
|| rawLines[heading.index - 2]?.trim().length !== 0
|
||||
|| modelView.index !== heading.index + 2
|
||||
|| tokenEffect.index !== modelView.index + 2) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: context-surface heading and fields require one blank line between each element` })
|
||||
|| fieldStarts[0].line.index !== heading.index + 2) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: context-surface heading and first field require one blank line between them` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const unexpected = entries.slice(3).find(line => !/^#### \S/.test(line.raw))
|
||||
if (unexpected !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${unexpected.index}: content after ${TOKEN_EFFECT_LABEL} must be a titled H4 plus \`markdown\` fence inside this context surface` })
|
||||
surfaceError = true
|
||||
break
|
||||
const parsedFields: ParsedField[] = []
|
||||
const verbatimFragments = new Set<string>()
|
||||
for (let fieldIndex = 0; fieldIndex < FIELD_HEADINGS.length; fieldIndex += 1) {
|
||||
const fieldStart = fieldStarts[fieldIndex] as { line: Line; index: number }
|
||||
const expectedHeading = FIELD_HEADINGS[fieldIndex] as string
|
||||
if (fieldStart.line.raw !== expectedHeading) {
|
||||
failures.push({ path: readme, message: `line ${fieldStart.line.index}: expected exact field heading ${JSON.stringify(expectedHeading)}, found ${JSON.stringify(fieldStart.line.raw)}` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const fieldEnd = fieldStarts[fieldIndex + 1]?.index ?? entries.length
|
||||
const fieldEntries = entries.slice(fieldStart.index, fieldEnd)
|
||||
const value = fieldEntries[1]
|
||||
if (value === undefined || /^#{1,6} /.test(value.raw) || value.raw.trim().length === 0) {
|
||||
failures.push({ path: readme, message: `line ${fieldStart.line.index}: ${expectedHeading} requires one non-empty paragraph` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (value.index !== fieldStart.line.index + 2) {
|
||||
failures.push({ path: readme, message: `line ${fieldStart.line.index}: ${expectedHeading} and its paragraph require one blank line between them` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const unexpected = fieldEntries.slice(2).find(line => !/^##### \S/.test(line.raw))
|
||||
if (unexpected !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${unexpected.index}: content after ${expectedHeading} paragraph must be a titled H5 plus \`markdown\` fence owned by that field` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const nextHeadingLine = fieldStarts[fieldIndex + 1]?.line.index
|
||||
?? surfaceStarts[surfaceIndex + 1]?.line.index
|
||||
?? nextH2Line
|
||||
if (rawLines[nextHeadingLine - 2]?.trim().length !== 0) {
|
||||
failures.push({ path: readme, message: `line ${nextHeadingLine}: Model Experience headings require a preceding blank line` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const verbatim = validateNestedVerbatim(rawLines.slice(value.index, nextHeadingLine - 1), verbatimFragments)
|
||||
if (verbatim.error !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${value.index}: ${verbatim.error}` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (fieldEntries.length - 2 !== verbatim.blocks) {
|
||||
failures.push({ path: readme, message: `line ${value.index}: every nested H5 must own exactly one \`markdown\` fence` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
parsedFields.push({ value, verbatimBlocks: verbatim.blocks })
|
||||
}
|
||||
const nextHeadingLine = surfaceStarts[surfaceIndex + 1]?.line.index ?? nextH2Line
|
||||
const verbatim = validateNestedVerbatim(rawLines.slice(tokenEffect.index, nextHeadingLine - 1))
|
||||
if (verbatim.error !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${tokenEffect.index}: ${verbatim.error}` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (entries.length - 3 !== verbatim.blocks) {
|
||||
failures.push({ path: readme, message: `line ${tokenEffect.index}: every nested H4 must own exactly one \`markdown\` fence` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (/\]\(#[^)]+\)/.test(modelView.raw) || /\]\(#[^)]+\)/.test(tokenEffect.raw)) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: Model Experience fields must not link between local subsections; nest the H4 in its owning H3` })
|
||||
if (surfaceError) break
|
||||
const modelViewField = parsedFields[0] as ParsedField
|
||||
const tokenEffectField = parsedFields[1] as ParsedField
|
||||
const kvCacheEffectField = parsedFields[2] as ParsedField
|
||||
const modelView = modelViewField.value
|
||||
const tokenEffect = tokenEffectField.value
|
||||
const kvCacheEffect = kvCacheEffectField.value
|
||||
if (/\]\(#[^)]+\)/.test(modelView.raw) || /\]\(#[^)]+\)/.test(tokenEffect.raw) || /\]\(#[^)]+\)/.test(kvCacheEffect.raw)) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: Model Experience fields must not link between local subsections; nest the H5 in its owning H4 field` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
surfaceFragments.add(fragment)
|
||||
surfaces.push({ heading, modelView, tokenEffect, title, verbatimBlocks: verbatim.blocks })
|
||||
surfaces.push({
|
||||
heading,
|
||||
modelView,
|
||||
tokenEffect,
|
||||
kvCacheEffect,
|
||||
title,
|
||||
modelViewVerbatimBlocks: modelViewField.verbatimBlocks,
|
||||
verbatimBlocks: parsedFields.reduce((total, field) => total + field.verbatimBlocks, 0),
|
||||
})
|
||||
}
|
||||
if (surfaceError) continue
|
||||
|
||||
const promptWithoutVerbatim = surfaces.find(surface => isDirectSystemPromptSurface(surface.title)
|
||||
&& surface.verbatimBlocks === 0)
|
||||
&& surface.modelViewVerbatimBlocks === 0)
|
||||
if (promptWithoutVerbatim !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${promptWithoutVerbatim.heading.index}: system-prompt surface must contain a titled H4 plus verbatim \`markdown\` block` })
|
||||
failures.push({ path: readme, message: `line ${promptWithoutVerbatim.heading.index}: system-prompt surface must contain a titled H5 plus verbatim \`markdown\` block under ${MODEL_VIEW_HEADING}` })
|
||||
continue
|
||||
}
|
||||
const hasConcreteLiteral = surfaces.some(surface => surface.verbatimBlocks > 0
|
||||
@@ -385,11 +455,12 @@ for (const packageJson of packageJsons) {
|
||||
contextSurfaceCount += surfaces.length
|
||||
systemPromptSurfaceCount += surfaces.filter(surface => isDirectSystemPromptSurface(surface.title)).length
|
||||
toolSchemaSurfaceCount += surfaces.filter(surface => /\bschemas?\b/i.test(surface.title)).length
|
||||
kvCacheEffectCount += surfaces.length
|
||||
structuredCount += 1
|
||||
}
|
||||
|
||||
if (failures.length === 0) {
|
||||
console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${omittedSectionCount} audited omissions, ${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
|
||||
console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${omittedSectionCount} audited omissions, ${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${kvCacheEffectCount} KV-cache fields, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Enforce RFC lifecycle/class paths, dated filenames, and titles; verify the
|
||||
* generated index and reject index rows in the curated README. Structural rules
|
||||
* and rendering are shared with `rfc-index.ts`; the closed classification
|
||||
* contract lives in `docs/rfc/README.md`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { INDEX_ROW, renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
|
||||
|
||||
const { rfcs, errors } = walkRfcTree()
|
||||
|
||||
if (errors.length === 0) {
|
||||
let index: string | undefined
|
||||
try {
|
||||
index = readFileSync(resolve(rfcRoot, 'INDEX.md'), 'utf8')
|
||||
} catch {
|
||||
// A missing INDEX.md is reported below as staleness, exactly like a drifted one.
|
||||
}
|
||||
if (renderIndex(rfcs) !== index) {
|
||||
errors.push('index: docs/rfc/INDEX.md is stale or missing — run `pnpm run gen-rfc-index` and commit the result')
|
||||
}
|
||||
const readme = readFileSync(resolve(rfcRoot, 'README.md'), 'utf8')
|
||||
for (const line of readme.split('\n')) {
|
||||
if (INDEX_ROW.test(line)) {
|
||||
errors.push(`readme: index-shaped row in the curated README (the list lives in INDEX.md): ${JSON.stringify(line.slice(0, 80))}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-rfc-classification: violations found:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
@@ -24,8 +24,18 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const listMode = process.argv.includes('--list')
|
||||
const writeMode = process.argv.includes('--write')
|
||||
|
||||
/** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */
|
||||
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml']
|
||||
/** Scope of the bilingual contract: root docs, Agent Notes, the docs tree, and the Python SDK tree. */
|
||||
const SCOPE_PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'README.i18n.yaml',
|
||||
'.agents/notes/**/*.md',
|
||||
'.agents/notes/**/*.i18n.yaml',
|
||||
'docs/**/*.md',
|
||||
'docs/**/*.i18n.yaml',
|
||||
'python/**/*.md',
|
||||
'python/**/*.i18n.yaml',
|
||||
]
|
||||
|
||||
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
|
||||
|
||||
@@ -121,8 +131,8 @@ for (const req of manifest.required) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Date-named documents (RFCs) dated on/after the requiredSince cutoff merge
|
||||
// bilingual: a new RFC lands with its pair or not at all. Deterministic from
|
||||
// 2. Date-named documents (Agent Notes) dated on/after the requiredSince cutoff merge
|
||||
// bilingual: a new Agent Note lands with its pair or not at all. Deterministic from
|
||||
// the filename alone — no git history, so it holds on shallow CI checkouts.
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
|
||||
@@ -14,7 +14,7 @@ import ts from 'typescript'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
|
||||
const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
|
||||
const MARKDOWN_GLOBS = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
|
||||
|
||||
/** One manifest entry: a source-equivalence block and its source symbol. */
|
||||
interface ManifestEntry {
|
||||
|
||||
Reference in New Issue
Block a user