From c431458110d5bcbd255f8213ced8b906b64b04cf Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Tue, 14 Jul 2026 05:12:43 +0100 Subject: [PATCH 1/2] chore: initialize work package for #224 V4 audience attribute Seed commit anchoring the draft PR for PR #1 of epic #224 (server-side V4: audience attribute on technique output declarations). Implementation follows. From 482332e467e577b6cf47f97229dc895b11a65660 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Tue, 14 Jul 2026 06:24:45 +0100 Subject: [PATCH 2/2] feat(technique-protocol): add audience attribute to output declarations Add an optional audience enum (human | agent) to technique output / artifact declarations, threaded end-to-end and additively: - schema: audience on OutputItemDefinitionSchema plus the hand-maintained technique.schema.json (kept in lockstep) - loader: parse the audience reserved sub-section on an output entry; strict-mode schema rejects invalid values - get_activity: composeActivityArtifacts carries audience into the artifacts contract and _meta.artifacts - docs: technique-protocol-specification section 3.2 documents the attribute, the agent-audience JSON convention, and human-vs-agent authoring guidance (decision test plus anti-patterns; per-artifact schemas are out of scope for this PR) - lint: standalone scripts/check-audience.ts guards the agent-JSON convention across the corpus - tests: parser, schema, projection carry-through, and contract coverage Declarations without audience remain valid (backward-compatible). PR #1 of epic #224 (V4). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- docs/development.md | 9 +- docs/technique-protocol-specification.md | 28 +++++ package.json | 3 +- schemas/technique.schema.json | 1 + scripts/audience-baseline.json | 1 + scripts/check-audience.ts | 153 +++++++++++++++++++++++ src/loaders/markdown-technique-loader.ts | 44 ++++--- src/schema/technique.schema.ts | 1 + src/tools/workflow-tools.ts | 10 +- tests/audience-guard.test.ts | 87 +++++++++++++ tests/compose-activity-artifacts.test.ts | 70 +++++++++++ tests/schema-validation.test.ts | 35 ++++++ tests/technique-loader.test.ts | 103 ++++++++++++++- 13 files changed, 522 insertions(+), 23 deletions(-) create mode 100644 scripts/audience-baseline.json create mode 100644 scripts/check-audience.ts create mode 100644 tests/audience-guard.test.ts create mode 100644 tests/compose-activity-artifacts.test.ts diff --git a/docs/development.md b/docs/development.md index e702b527..067261b4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -182,9 +182,16 @@ npm run check:technique-template # and no identical rule/checkpoint body authored inline at multiple sites. # Near-duplicates of a fragment are reported as warnings. Hard-zero. npm run check:fragments + +# Audience convention: every output declared audience: agent that also carries an +# #### artifact filename must name a JSON artifact (an agent-audience artifact is +# serialized as JSON on disk). Fails only on NEW violations beyond +# scripts/audience-baseline.json (re-snapshot intentional changes with +# --update-baseline). +npm run check:audience ``` -The binding-fidelity, technique-template, and fragments guards also run as Vitest tests (`tests/binding-fidelity.test.ts`, `tests/technique-template.test.ts`, `tests/fragments-guard.test.ts`), so `npm test` fails on new binding drift, a template deviation, or fragment drift. +The binding-fidelity, technique-template, fragments, and audience guards also run as Vitest tests (`tests/binding-fidelity.test.ts`, `tests/technique-template.test.ts`, `tests/fragments-guard.test.ts`, `tests/audience-guard.test.ts`), so `npm test` fails on new binding drift, a template deviation, fragment drift, or a non-JSON agent-audience artifact. ## Branch Structure diff --git a/docs/technique-protocol-specification.md b/docs/technique-protocol-specification.md index 0d53babd..537a9a35 100644 --- a/docs/technique-protocol-specification.md +++ b/docs/technique-protocol-specification.md @@ -68,6 +68,8 @@ metadata: #### artifact (optional: the persistence filename) `` +#### audience (optional: the intended reader — human | agent) +`agent` ## Protocol (present when the technique does work) ### . @@ -93,12 +95,38 @@ loader rejects the singular `## Input` / `## Output` (and `## Output(s)`) varian - `#### <member>` is a named component of the entry (`components[member]`). - `#### artifact` (Outputs) is the persistence filename — a literal (`code-review.md`) or a `{token}`-template the worker interpolates at runtime (`{package_name}-plan.md`, the token being a snake_case symbol). +- `#### audience` (Outputs) is the intended reader of the output/artifact — `human` or `agent`. + Absent means `human`. An `agent`-audience artifact is serialized as **JSON** on disk (named under + the same `artifactPrefix` rule as any artifact); a `human`-audience artifact is prose markdown. - `#### default` (Inputs) is the input's default value. - An entry whose description opens with `optional` (e.g. `*(optional)*`) is `required: false`. `parseEntrySubsections` splits an entry's lead description from its `####` members for both inputs and outputs. +##### Choosing the audience + +`audience` makes format follow function, so pick it from who reads the artifact: + +- **`agent`** — an artifact written *only* for the next agent to consume as state: ID-bearing + tables, routing / reconciliation / index state, anything a later step reads back mechanically. +- **`human`** — an artifact a person reads linearly: design write-ups, summaries, READMEs. +- **absent ⇒ `human`** — the default when a declaration omits `audience`. + +Anti-patterns, per side: + +- **`agent`**: no prose narrative and no restating another artifact — reference it by ID or link and + keep the content structured JSON. +- **`human`**: keep the existing state-once, single-source-and-link, and exception-only reporting + discipline. +- **cross-cutting**: don't serialize agent state as prose, and don't dress a human document up as a + data dump. + +*Out of scope:* the per-artifact JSON *field schemas* for specific agent artifacts (assumptions-log, +findings-classification, etc.) are a later increment (V5), not part of the `audience` attribute +itself. This attribute states *who reads* an artifact and *that* an agent artifact is JSON; it does +not fix the shape of any particular JSON payload. + #### The symbol model A technique has one namespace of **mutable symbols**. Direction is **structural — carried by the diff --git a/package.json b/package.json index 39213fa7..c8e10146 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "check:variable-model": "tsx scripts/check-variable-model.ts", "check:fragments": "tsx scripts/check-fragments.ts", "check:review-mode": "tsx scripts/check-review-mode-gating.ts", - "check:stealth": "tsx scripts/check-stealth-isolation.ts" + "check:stealth": "tsx scripts/check-stealth-isolation.ts", + "check:audience": "tsx scripts/check-audience.ts" }, "license": "MIT", "devDependencies": { diff --git a/schemas/technique.schema.json b/schemas/technique.schema.json index 774c267e..a5375644 100644 --- a/schemas/technique.schema.json +++ b/schemas/technique.schema.json @@ -69,6 +69,7 @@ "description": { "type": "string", "description": "Human-readable description of this output" }, "components": { "$ref": "#/definitions/outputComponentsDefinition" }, "artifact": { "$ref": "#/definitions/outputArtifact", "description": "Optional. When populated, specifies the artifact name to create when persisting this output." }, + "audience": { "type": "string", "enum": ["human", "agent"], "description": "Optional. The intended reader of this output/artifact — `human` (a person reads it linearly) or `agent` (the next agent consumes it as state). Absent means `human`. An `agent`-audience artifact is serialized as JSON on disk under the artifactPrefix rule." }, "destination": { "type": "string", "description": "Delivery-only, server-populated on a step-bound get_technique: the session-bag name this output lands under when the step binding remaps it. Absent otherwise — an unremapped output lands under its own id. Never authored in technique markdown." } }, "required": [ "id" ], diff --git a/scripts/audience-baseline.json b/scripts/audience-baseline.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/scripts/audience-baseline.json @@ -0,0 +1 @@ +[] diff --git a/scripts/check-audience.ts b/scripts/check-audience.ts new file mode 100644 index 00000000..64d336e8 --- /dev/null +++ b/scripts/check-audience.ts @@ -0,0 +1,153 @@ +/** + * check-audience — agent-audience artifact JSON-format convention guard (#224 V4). + * + * An output declared with `#### audience` = `agent` is written for the next agent to consume as + * state, and by convention (docs/technique-protocol-specification.md §3.2) an agent-audience + * artifact is serialized as JSON on disk. This guard walks every technique `.md` in the corpus + * through the real loader and, for each output that is BOTH `audience: agent` AND carries an + * `#### artifact` filename, asserts the artifact name follows the JSON-format convention: it (or, + * for a `{token}`-template name, its literal suffix) ends in `.json`. + * + * This is a distinct concern from check-binding-fidelity.ts — that guard checks input/output + * binding conformance and treats `#### artifact` as opaque presence. The audience/JSON-format + * convention is a separate one-guard-per-concern check, so it lives in its own script. Enum + * *validity* (`human`|`agent`) is already enforced by the Zod `.strict()` schema at load; this + * guard checks the on-disk *format* convention the schema cannot express. + * + * The current corpus carries no agent-audience adoption, so the expected baseline is empty. Any + * violation is snapshotted in scripts/audience-baseline.json; the guard fails ONLY on violations + * absent from that baseline (i.e. a NEW agent-audience artifact whose name is not JSON). Regenerate + * the baseline after an intentional, reviewed change with: + * npx tsx scripts/check-audience.ts --update-baseline + * + * Run: npx tsx scripts/check-audience.ts [--root <workflows-dir>] + * To check a dedicated worktree's workflows instead of the repo's own ../workflows, pass + * `--root <path>` (or set WORKFLOWS_DIR). + */ +import { readFileSync, readdirSync, existsSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { tryLoadMarkdownTechnique, tryLoadNestedTechnique } from '../src/loaders/markdown-technique-loader.js'; +import { resolveWorkflowsRoot } from './workflows-root.js'; + +const DIR = fileURLToPath(new URL('.', import.meta.url)); +const ROOT = resolveWorkflowsRoot(resolve(join(DIR, '..', 'workflows'))); +const BASELINE = join(DIR, 'audience-baseline.json'); + +const GROUPED_INDEX = 'TECHNIQUE.md'; + +export interface AudienceViolation { + /** `<workflow>::<technique-id>::<output-id>` — stable key for the baseline. */ + key: string; + detail: string; +} + +/** + * An agent-audience artifact must be JSON on disk. Accept a name whose literal suffix is `.json` + * — including a `{token}`-templated name (`{package_name}-state.json`) whose fixed tail is `.json`. + */ +function isJsonArtifactName(name: string): boolean { + return /\.json$/i.test(name.trim()); +} + +type LoadedTechnique = NonNullable<Awaited<ReturnType<typeof tryLoadMarkdownTechnique>>>; + +/** + * Load a technique file, tolerating non-technique `.md` files that share the `techniques/` tree. + * The loader THROWS a parse error on a file with no `## Capability` / no `metadata.version` (e.g. a + * `README.md`); such a file is not a technique, so the walker skips it rather than crashing the + * guard. A file that parses but fails schema validation (a genuinely malformed technique) returns + * null from the loader and is likewise skipped — the schema-validation test owns that failure. + */ +async function tryLoad(load: () => Promise<LoadedTechnique | null>): Promise<LoadedTechnique | null> { + try { return await load(); } catch { return null; } +} + +/** Load every technique in a workflow's `techniques/` tree through the real loader. */ +async function loadWorkflowTechniques(techniquesDir: string): Promise<Array<{ id: string; technique: LoadedTechnique }>> { + const out: Array<{ id: string; technique: LoadedTechnique }> = []; + if (!existsSync(techniquesDir)) return out; + for (const entry of readdirSync(techniquesDir).sort()) { + const full = join(techniquesDir, entry); + if (statSync(full).isDirectory()) { + // Grouped technique: `<group>/TECHNIQUE.md` index + `<group>/<op>.md` children. + if (existsSync(join(full, GROUPED_INDEX))) { + const index = await tryLoad(() => tryLoadMarkdownTechnique(techniquesDir, entry)); + if (index) out.push({ id: entry, technique: index }); + } + for (const child of readdirSync(full).sort()) { + if (!child.endsWith('.md') || child === GROUPED_INDEX) continue; + const opName = child.slice(0, -'.md'.length); + const op = await tryLoad(() => tryLoadNestedTechnique(techniquesDir, entry, opName)); + if (op) out.push({ id: `${entry}::${opName}`, technique: op }); + } + } else if (entry.endsWith('.md') && entry !== GROUPED_INDEX) { + // Flat standalone technique `<id>.md`. + const id = entry.slice(0, -'.md'.length); + const t = await tryLoad(() => tryLoadMarkdownTechnique(techniquesDir, id)); + if (t) out.push({ id, technique: t }); + } + } + return out; +} + +export async function collectAudienceViolations(root: string = ROOT): Promise<AudienceViolation[]> { + const out: AudienceViolation[] = []; + if (!existsSync(root)) return out; + for (const workflow of readdirSync(root).sort()) { + const techniquesDir = join(root, workflow, 'techniques'); + if (!existsSync(techniquesDir) || !statSync(techniquesDir).isDirectory()) continue; + for (const { id, technique } of await loadWorkflowTechniques(techniquesDir)) { + for (const o of technique.outputs ?? []) { + // Only agent-audience outputs that also declare an artifact filename are in scope: those + // are the artifacts written to disk that the convention says must be JSON. + if (o.audience !== 'agent') continue; + const name = o.artifact?.name; + if (!name) continue; + if (!isJsonArtifactName(name)) { + out.push({ + key: `${workflow}::${technique.id}::${o.id}`, + detail: `output '${o.id}' in technique '${id}' is audience: agent but its artifact name '${name}' is not JSON — an agent-audience artifact is serialized as JSON on disk (rename to a .json filename)`, + }); + } + } + } + } + return out.sort((a, b) => a.key.localeCompare(b.key)); +} + +function loadBaseline(): Set<string> { + if (!existsSync(BASELINE)) return new Set(); + try { return new Set(JSON.parse(readFileSync(BASELINE, 'utf-8')) as string[]); } catch { return new Set(); } +} + +/** Violations not in the committed baseline (`added`) and baselined keys no longer present (`fixed`). */ +export async function diffBaseline(root: string = ROOT): Promise<{ added: AudienceViolation[]; fixed: string[] }> { + const all = await collectAudienceViolations(root); + const baseline = loadBaseline(); + return { + added: all.filter(v => !baseline.has(v.key)), + fixed: [...baseline].filter(k => !all.some(v => v.key === k)), + }; +} + +const isMain = !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + const all = await collectAudienceViolations(); + if (process.argv.includes('--update-baseline')) { + writeFileSync(BASELINE, JSON.stringify(all.map(v => v.key), null, 2) + '\n'); + process.stdout.write(`audience: baseline updated with ${all.length} entr(ies) at ${relative(process.cwd(), BASELINE)}\n`); + process.exit(0); + } + const baseline = loadBaseline(); + const fresh = all.filter(v => !baseline.has(v.key)); + const fixed = [...baseline].filter(k => !all.some(v => v.key === k)); + if (fresh.length === 0) { + process.stdout.write(`audience: OK — ${all.length} total, ${baseline.size} baselined, 0 NEW${fixed.length ? `, ${fixed.length} fixed` : ''}\n`); + if (fixed.length) process.stdout.write(` ${fixed.length} baselined entr(ies) no longer present — run --update-baseline to shrink the baseline\n`); + process.exit(0); + } + process.stdout.write(`audience: ${fresh.length} NEW violation(s) — an agent-audience artifact is not JSON:\n`); + for (const v of fresh) process.stdout.write(` ${v.key} — ${v.detail}\n`); + process.exit(1); +} diff --git a/src/loaders/markdown-technique-loader.ts b/src/loaders/markdown-technique-loader.ts index b9f02b91..f12bfe47 100644 --- a/src/loaders/markdown-technique-loader.ts +++ b/src/loaders/markdown-technique-loader.ts @@ -241,7 +241,9 @@ interface IndexParse { capability: string; inputs: Array<{ id: string; description?: string; required?: boolean; components?: Record<string, string>; default?: string }> | undefined; protocol: ProtocolBlock[] | undefined; - outputs: Array<{ id: string; description?: string; artifact?: { name: string }; components?: Record<string, string> }> | undefined; + // `audience` is carried as an unrefined string so a mistyped value reaches OutputItemDefinitionSchema's + // `human`/`agent` enum and is rejected loudly at load, rather than being narrowed away here. + outputs: Array<{ id: string; description?: string; artifact?: { name: string }; audience?: string; components?: Record<string, string> }> | undefined; rules: Record<string, string | string[]> | undefined; } @@ -285,31 +287,36 @@ function parseTechniqueIndex(raw: string, sourcePath: string, id: string): Index }; } +/** Reserved `####` sub-section keys per entry kind: `default` on inputs, `artifact`/`audience` on + * outputs. A sub-section whose title matches one of these is entry metadata, not a component. */ +type ReservedKey = 'artifact' | 'default' | 'audience'; + /** * Split an Inputs/Output entry body into its lead description and `####` sub-sections. - * Each sub-section names a component member of the entry; a sub-section whose title equals - * `reserved` (case-insensitive) is pulled out as entry metadata instead of a component — - * `artifact` for outputs (the persistence filename), `default` for inputs (the default value). - * Returns the lead description, the component map (excluding the reserved member), and the - * reserved member's value when present. + * Each sub-section names a component member of the entry; a sub-section whose title matches one of + * the `reserved` keys (case-insensitive) is pulled out as entry metadata instead of a component — + * `artifact`/`audience` for outputs (the persistence filename and intended reader), `default` for + * inputs (the default value). Returns the lead description, the component map (excluding reserved + * members), and a map of each matched reserved member's value. */ function parseEntrySubsections( body: string, - reserved: 'artifact' | 'default', -): { description?: string; components?: Record<string, string>; reserved?: string } { + reserved: readonly ReservedKey[], +): { description?: string; components?: Record<string, string>; reserved: Partial<Record<ReservedKey, string>> } { const subs = splitSections(body, 4); // Lead description is everything before the first `#### ` heading. const firstHeading = body.search(/^####\s/m); const lead = firstHeading === -1 ? body : body.slice(0, firstHeading); const description = bodyParagraphs(lead) || undefined; - const out: { description?: string; components?: Record<string, string>; reserved?: string } = {}; + const out: { description?: string; components?: Record<string, string>; reserved: Partial<Record<ReservedKey, string>> } = { reserved: {} }; if (description) out.description = description; const components: Record<string, string> = {}; for (const s of subs) { const value = bodyParagraphs(s.body); - if (s.title.toLowerCase() === reserved) { - // Strip surrounding inline-code backticks from a filename/default literal. - out.reserved = value.replace(/^`+|`+$/g, '').trim(); + const key = reserved.find((r) => r === s.title.toLowerCase()); + if (key) { + // Strip surrounding inline-code backticks from a filename/default/enum literal. + out.reserved[key] = value.replace(/^`+|`+$/g, '').trim(); } else { components[s.title] = value; } @@ -324,13 +331,13 @@ function parseInputsSection(section: Section | undefined): IndexParse['inputs'] if (items.length === 0) return undefined; const result: NonNullable<IndexParse['inputs']> = []; for (const item of items) { - const { description, components, reserved } = parseEntrySubsections(item.body, 'default'); + const { description, components, reserved } = parseEntrySubsections(item.body, ['default']); const entry: { id: string; description?: string; components?: Record<string, string>; default?: string } = { id: item.title }; // A leading "(optional)" stays in the description prose — optionality is conveyed at the // point of use, not synthesized into a flag (the retired `required` field was never enforced). if (description) entry.description = description; if (components) entry.components = components; - if (reserved !== undefined) entry.default = reserved; + if (reserved.default !== undefined) entry.default = reserved.default; result.push(entry); } return result; @@ -382,16 +389,21 @@ function parseOutputsSection(section: Section | undefined): IndexParse['outputs' if (items.length === 0) return undefined; const result: NonNullable<IndexParse['outputs']> = []; for (const item of items) { - const { description, components, reserved } = parseEntrySubsections(item.body, 'artifact'); + const { description, components, reserved } = parseEntrySubsections(item.body, ['artifact', 'audience']); const out: { id: string; description?: string; artifact?: { name: string }; + audience?: string; components?: Record<string, string>; } = { id: item.title }; if (description) out.description = description; if (components) out.components = components; - if (reserved !== undefined) out.artifact = { name: reserved }; + if (reserved.artifact !== undefined) out.artifact = { name: reserved.artifact }; + // Pass the authored value through verbatim; the `human`/`agent` enum on OutputItemDefinitionSchema + // is the single validator, so a mistyped audience fails loudly at load (technique dropped with a + // logged warning) rather than being silently discarded here. + if (reserved.audience !== undefined) out.audience = reserved.audience; result.push(out); } return result; diff --git a/src/schema/technique.schema.ts b/src/schema/technique.schema.ts index 1a5e4d19..62bc56d7 100644 --- a/src/schema/technique.schema.ts +++ b/src/schema/technique.schema.ts @@ -54,6 +54,7 @@ export const OutputItemDefinitionSchema = z.object({ description: z.string().optional().describe('Human-readable description of this output'), components: OutputComponentsDefinitionSchema.optional(), artifact: OutputArtifactSchema.optional().describe('Optional. When populated, specifies the artifact name to create when persisting this output.'), + audience: z.enum(['human', 'agent']).optional().describe('Optional. The intended reader of this output/artifact — `human` (a person reads it linearly) or `agent` (the next agent consumes it as state). Absent means `human`. An `agent`-audience artifact is serialized as JSON on disk under the `artifactPrefix` rule.'), destination: z.string().optional().describe('Delivery-only, populated by the server on a step-bound get_technique: the session-bag name this output lands under when the step binding remaps it. Absent otherwise — an unremapped output lands under its own id. Never authored in technique files.'), }); export type OutputItemDefinition = z.infer<typeof OutputItemDefinitionSchema>; diff --git a/src/tools/workflow-tools.ts b/src/tools/workflow-tools.ts index c1e235f7..95bbfaf4 100644 --- a/src/tools/workflow-tools.ts +++ b/src/tools/workflow-tools.ts @@ -76,7 +76,7 @@ export async function composeActivityArtifacts( workflowDir: string, workflowId: string, activityId?: string, -): Promise<Array<{ id: string; name: string }>> { +): Promise<Array<{ id: string; name: string; audience?: 'human' | 'agent' }>> { if (!activity) return []; const refs = new Set<string>(); const collect = (steps?: Array<StepLike>): void => { @@ -97,14 +97,16 @@ export async function composeActivityArtifacts( if (activityId && !r.includes('::')) candidates.add(`${activityId}::${r}`); } const resolved = await resolveTechniques([...candidates], workflowDir, workflowId); - const artifacts: Array<{ id: string; name: string }> = []; + const artifacts: Array<{ id: string; name: string; audience?: 'human' | 'agent' }> = []; const seen = new Set<string>(); for (const t of resolved) { if (t.type !== 'technique') continue; - const outputs = (t.body as { outputs?: Array<{ id?: string; artifact?: { name?: string } }> } | undefined)?.outputs ?? []; + const outputs = (t.body as { outputs?: Array<{ id?: string; artifact?: { name?: string }; audience?: 'human' | 'agent' }> } | undefined)?.outputs ?? []; for (const o of outputs) { const name = o.artifact?.name; - if (name && !seen.has(name)) { seen.add(name); artifacts.push({ id: o.id ?? name, name }); } + // Carry `audience` onto the contract entry so the worker knows an artifact's intended reader + // (and thus its on-disk format) at write time; absent when the output declares none. + if (name && !seen.has(name)) { seen.add(name); artifacts.push({ id: o.id ?? name, name, ...(o.audience ? { audience: o.audience } : {}) }); } } } return artifacts; diff --git a/tests/audience-guard.test.ts b/tests/audience-guard.test.ts new file mode 100644 index 00000000..be314408 --- /dev/null +++ b/tests/audience-guard.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { collectAudienceViolations, diffBaseline } from '../scripts/check-audience.js'; + +/** + * Audience convention guard (#224 V4): an output declared `audience: agent` that also carries an + * `#### artifact` filename must name a JSON artifact — an agent-audience artifact is serialized as + * JSON on disk (docs/technique-protocol-specification.md §3.2). The corpus carries no agent-audience + * adoption yet, so beyond the committed baseline (scripts/audience-baseline.json) the set is empty. + */ + +const FM = ['---', 'metadata:', ' version: 1.0.0', '---', '']; + +async function writeTechnique(techniquesDir: string, id: string, outputsBody: string[]): Promise<void> { + await mkdir(techniquesDir, { recursive: true }); + await writeFile( + join(techniquesDir, `${id}.md`), + [...FM, '## Capability', '', 'Cap.', '', '## Outputs', '', ...outputsBody, ''].join('\n'), + 'utf-8', + ); +} + +describe('audience guard (corpus)', () => { + // PR227-TC-10 — the real corpus introduces no violations beyond the committed baseline. + it('introduces no NEW non-JSON agent-audience artifacts beyond the baseline', async () => { + const { added } = await diffBaseline(); + expect(added.map((v) => `${v.key} — ${v.detail}`)).toEqual([]); + }); +}); + +describe('audience guard (fixture corpus)', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await import('node:fs/promises').then((fs) => fs.mkdtemp(join(tmpdir(), 'audience-guard-'))); + }); + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + // PR227-TC-09 — an agent-audience artifact whose name is not JSON is flagged. + it('flags an agent-audience output whose artifact name is not JSON', async () => { + const dir = join(tempDir, 'fixture-wf', 'techniques'); + await writeTechnique(dir, 'bad', [ + '### state_log', '', 'Agent state.', '', + '#### artifact', '', '`assumptions-log.md`', '', + '#### audience', '', '`agent`', + ]); + const violations = await collectAudienceViolations(tempDir); + expect(violations.map((v) => v.key)).toEqual(['fixture-wf::bad::state_log']); + expect(violations[0]!.detail).toContain('assumptions-log.md'); + }); + + it('passes a JSON-named agent-audience artifact and a human-audience markdown artifact', async () => { + const dir = join(tempDir, 'fixture-wf', 'techniques'); + await writeTechnique(dir, 'ok-agent', [ + '### state_log', '', 'Agent state.', '', + '#### artifact', '', '`assumptions-log.json`', '', + '#### audience', '', '`agent`', + ]); + await writeTechnique(dir, 'ok-human', [ + '### summary', '', 'A human summary.', '', + '#### artifact', '', '`design-review.md`', '', + '#### audience', '', '`human`', + ]); + // An agent-audience output with NO artifact is out of scope (nothing written to disk to check). + await writeTechnique(dir, 'ok-no-artifact', [ + '### transient_state', '', 'Bag-only state.', '', + '#### audience', '', '`agent`', + ]); + const violations = await collectAudienceViolations(tempDir); + expect(violations).toEqual([]); + }); + + it('accepts a {token}-templated agent artifact whose fixed suffix is .json', async () => { + const dir = join(tempDir, 'fixture-wf', 'techniques'); + await writeTechnique(dir, 'templated', [ + '### state_log', '', 'Agent state.', '', + '#### artifact', '', '`{package_name}-state.json`', '', + '#### audience', '', '`agent`', + ]); + const violations = await collectAudienceViolations(tempDir); + expect(violations).toEqual([]); + }); +}); diff --git a/tests/compose-activity-artifacts.test.ts b/tests/compose-activity-artifacts.test.ts new file mode 100644 index 00000000..6bdb2b64 --- /dev/null +++ b/tests/compose-activity-artifacts.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { composeActivityArtifacts } from '../src/tools/workflow-tools.js'; + +/** + * get_activity artifacts-contract carry-through (#224 V4, Task 3): composeActivityArtifacts + * synthesizes an activity's artifact contract from the `## Outputs` of the techniques its steps + * bind. An output's `audience` must ride onto the contract entry so the worker knows an artifact's + * intended reader (and on-disk format) at write time. The same array is emitted verbatim into the + * get_activity body `{artifacts}` block and `_meta.artifacts` (workflow-tools.ts), so covering the + * composed array covers both delivery surfaces (PR227-TC-07 / PR227-TC-08). + */ + +const FM = ['---', 'metadata:', ' version: 1.0.0', '---', '']; + +describe('composeActivityArtifacts audience carry-through', () => { + let tempDir: string; + const WF = 'fixture-wf'; + + beforeEach(async () => { + tempDir = await import('node:fs/promises').then((fs) => fs.mkdtemp(join(tmpdir(), 'compose-artifacts-'))); + }); + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function writeOp(id: string, outputsBody: string[]): Promise<void> { + const dir = join(tempDir, WF, 'techniques'); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, `${id}.md`), + [...FM, '## Capability', '', 'Cap.', '', '## Outputs', '', ...outputsBody, ''].join('\n'), + 'utf-8', + ); + } + + it('carries audience (agent and human) onto the contract entry, and omits it when absent', async () => { + await writeOp('agent-op', [ + '### state_log', '', 'Agent state.', '', + '#### artifact', '', '`assumptions-log.json`', '', + '#### audience', '', '`agent`', + ]); + await writeOp('human-op', [ + '### summary', '', 'A human summary.', '', + '#### artifact', '', '`design-review.md`', '', + '#### audience', '', '`human`', + ]); + await writeOp('plain-op', [ + '### report', '', 'An artifact with no declared audience.', '', + '#### artifact', '', '`plain-report.md`', + ]); + + const activity = { + steps: [ + { technique: 'agent-op' }, + { technique: 'human-op' }, + { technique: 'plain-op' }, + ], + }; + const artifacts = await composeActivityArtifacts(activity, tempDir, WF, 'some-activity'); + + const byName = Object.fromEntries(artifacts.map((a) => [a.name, a])); + expect(byName['assumptions-log.json']!.audience).toBe('agent'); + expect(byName['design-review.md']!.audience).toBe('human'); + // An output with no declared audience carries no audience key on the contract entry. + expect('audience' in byName['plain-report.md']!).toBe(false); + }); +}); diff --git a/tests/schema-validation.test.ts b/tests/schema-validation.test.ts index cf56aec5..f1e27d60 100644 --- a/tests/schema-validation.test.ts +++ b/tests/schema-validation.test.ts @@ -13,6 +13,10 @@ import { safeValidateActivity, } from '../src/schema/activity.schema.js'; import { ConditionSchema } from '../src/schema/condition.schema.js'; +import { + OutputItemDefinitionSchema, + safeValidateTechnique, +} from '../src/schema/technique.schema.js'; import { loadWorkflow, getActivity } from '../src/loaders/workflow-loader.js'; const WORKFLOW_DIR = resolve(import.meta.dirname, '../workflows'); @@ -286,6 +290,37 @@ describe('schema-validation', () => { }); }); + describe('OutputItemDefinitionSchema audience (#224 V4)', () => { + // PR227-TC-04 — the enum accepts both in-set values. + it('accepts audience: human and audience: agent', () => { + expect(OutputItemDefinitionSchema.safeParse({ id: 'out', audience: 'human' }).success).toBe(true); + expect(OutputItemDefinitionSchema.safeParse({ id: 'out', audience: 'agent' }).success).toBe(true); + }); + + // PR227-TC-03 (schema view) — audience is optional; omitting it is valid (backward compatible). + it('accepts an output with no audience', () => { + expect(OutputItemDefinitionSchema.safeParse({ id: 'out' }).success).toBe(true); + }); + + // PR227-TC-05 — an out-of-set value is rejected. + it('rejects an out-of-set audience value', () => { + const result = OutputItemDefinitionSchema.safeParse({ id: 'out', audience: 'robot' }); + expect(result.success).toBe(false); + }); + + // PR227-TC-05 (composed) — TechniqueSchema is `.strict()`, so an invalid audience on a nested + // output fails the whole technique, which is exactly what drops it at load. + it('rejects an invalid audience through the strict TechniqueSchema', () => { + const bad = { + id: 't', version: '1.0.0', capability: 'Cap.', + outputs: [{ id: 'out', audience: 'nobody' }], + }; + expect(safeValidateTechnique(bad).success).toBe(false); + const good = { ...bad, outputs: [{ id: 'out', audience: 'agent' }] }; + expect(safeValidateTechnique(good).success).toBe(true); + }); + }); + describe('corpus strict-parse', () => { // Every definition file of every workflow must parse under the closed schemas. This is the // guardrail for the loader's skip-on-validation-failure behavior: a schema tightening that diff --git a/tests/technique-loader.test.ts b/tests/technique-loader.test.ts index df1e8c0a..07489be5 100644 --- a/tests/technique-loader.test.ts +++ b/tests/technique-loader.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { readTechnique, projectTechniqueToYaml, composeTechnique, resolveTechniques } from '../src/loaders/technique-loader.js'; +import { readTechnique, projectTechnique, projectTechniqueToYaml, composeTechnique, resolveTechniques } from '../src/loaders/technique-loader.js'; import { resolve, join } from 'node:path'; import { mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -681,4 +681,105 @@ describe('technique-loader', () => { } }); }); + + /* ------------------------------------------------------------------------ */ + /* audience attribute (#224 V4): loader parse + projection carry-through */ + /* ------------------------------------------------------------------------ */ + + describe('audience attribute', () => { + let tempDir: string; + const FM = ['---', 'metadata:', ' version: 1.0.0', '---', '']; + + beforeEach(async () => { + tempDir = await import('node:fs/promises').then((fs) => fs.mkdtemp(join(tmpdir(), 'technique-audience-'))); + }); + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function writeTechnique(audienceLine: string[]): Promise<void> { + const dir = join(tempDir, 'meta', 'techniques'); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'aud.md'), + [...FM, '## Capability', '', 'Cap.', '', + '## Outputs', '', '### state_log', '', 'The output.', ...audienceLine, ''].join('\n'), + 'utf-8', + ); + } + + // PR227-TC-01 + it('parses `#### audience` = human onto the output entry', async () => { + await writeTechnique(['', '#### audience', '', '`human`']); + const result = await readTechnique('aud', tempDir); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.outputs?.find((o) => o.id === 'state_log')?.audience).toBe('human'); + } + }); + + // PR227-TC-02 + it('parses `#### audience` = agent onto the output entry (alongside an artifact)', async () => { + const dir = join(tempDir, 'meta', 'techniques'); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'aud.md'), + [...FM, '## Capability', '', 'Cap.', '', + '## Outputs', '', '### state_log', '', 'The output.', '', + '#### artifact', '', '`assumptions-log.json`', '', + '#### audience', '', '`agent`', ''].join('\n'), + 'utf-8', + ); + const result = await readTechnique('aud', tempDir); + expect(result.success).toBe(true); + if (result.success) { + const out = result.value.outputs?.find((o) => o.id === 'state_log'); + expect(out?.audience).toBe('agent'); + expect(out?.artifact?.name).toBe('assumptions-log.json'); + } + }); + + // PR227-TC-03 — backward compatibility + it('loads an output with no `#### audience` (audience absent)', async () => { + await writeTechnique([]); + const result = await readTechnique('aud', tempDir); + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.outputs?.find((o) => o.id === 'state_log')?.audience).toBeUndefined(); + } + }); + + // PR227-TC-05 (loader path) — an out-of-set value is passed through and rejected by .strict() + it('drops a technique whose `#### audience` is out of the human|agent set', async () => { + await writeTechnique(['', '#### audience', '', '`robot`']); + // A malformed field fails schema validation; the loader logs a warning and returns null, + // which readTechnique surfaces as a not-found/err rather than a technique carrying `robot`. + const result = await readTechnique('aud', tempDir); + expect(result.success).toBe(false); + }); + + // PR227-TC-06 — projection carry-through, no source edit to projectTechnique + it('projectTechnique / projectTechniqueToYaml preserve audience unchanged', async () => { + const dir = join(tempDir, 'meta', 'techniques'); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'aud.md'), + [...FM, '## Capability', '', 'Cap.', '', + '## Outputs', '', '### state_log', '', 'The output.', '', + '#### artifact', '', '`assumptions-log.json`', '', + '#### audience', '', '`agent`', ''].join('\n'), + 'utf-8', + ); + const result = await readTechnique('aud', tempDir); + expect(result.success).toBe(true); + if (result.success) { + // Object projection preserves the field verbatim. + const projected = projectTechnique(result.value) as { outputs?: Array<{ id: string; audience?: string }> }; + expect(projected.outputs?.find((o) => o.id === 'state_log')?.audience).toBe('agent'); + // YAML projection round-trips it too. + const decoded = parseYaml(projectTechniqueToYaml(result.value)) as { outputs?: Array<{ id: string; audience?: string }> }; + expect(decoded.outputs?.find((o) => o.id === 'state_log')?.audience).toBe('agent'); + } + }); + }); });