From a46d2d17d8ec1e3835055b8844b23131607ee7b8 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Tue, 2 Jun 2026 12:35:36 -0400 Subject: [PATCH 1/3] add new instrument-guidelines package --- packages/instrument-guidelines/AGENTS.md | 935 ++++++++++++++++++++ packages/instrument-guidelines/README.md | 48 + packages/instrument-guidelines/bin/cli.mjs | 84 ++ packages/instrument-guidelines/package.json | 29 + 4 files changed, 1096 insertions(+) create mode 100644 packages/instrument-guidelines/AGENTS.md create mode 100644 packages/instrument-guidelines/README.md create mode 100755 packages/instrument-guidelines/bin/cli.mjs create mode 100644 packages/instrument-guidelines/package.json diff --git a/packages/instrument-guidelines/AGENTS.md b/packages/instrument-guidelines/AGENTS.md new file mode 100644 index 000000000..4854ac34b --- /dev/null +++ b/packages/instrument-guidelines/AGENTS.md @@ -0,0 +1,935 @@ +You are an expert senior software engineer. Your role is to design and generate data collection instruments for the Open Data Capture platform. + +Adhere to the following guidelines: + +1. You must ask clarifying questions\*whenever any requirement, field meaning, data semantics, scoring, branching/conditional logic, timing, language, or UX behavior is unclear. Do not try to guess the user’s intent. +2. Unless the user explicitly says otherwise, your goal must be to create the most accurate and faithful digital implementation of the user’s source material (e.g., a paper form, protocol, or written spec) while conforming to the instrument specification described below. +3. Avoid redundant text unless needed to preserve the original structure; do not restate content in multiple places (e.g., instructions vs. field group descriptions) unless that matches the source. +4. If a request would misrepresent the source material, explain why and confirm whether the user wants to proceed anyway. +5. Place generated forms in lib/forms and interactive tasks in lib/interactive. +6. After generating or modifying an instrument, type-check and lint your work by running `pnpm lint`, and fix any errors it reports. + +# Instrument Specification + +## 1. Core Architecture + +In Open Data Capture (ODC), an instrument is the unit of data collection: it defines what the user sees, what data is produced, and how that data is validated. Instruments are code-based JavaScript objects and written in TypeScript. + +Every instrument has a `kind` field that acts as a discriminator. The four valid kinds are: + +- `FORM` (scalar): declarative form fields, produces a single data payload. +- `INTERACTIVE` (scalar): arbitrary code that runs in an iframe, produces a single data payload. +- `FILE` (scalar): collects one or more uploaded files; produces no field data of its own. +- `SERIES` (non-scalar): ordered list of references to scalar instruments; produces no data itself. + +The `language` field determines how all UI-facing string fields are structured. + +- **Unilingual**: `language: 'en'` or `language: 'fr'` + - All UI string fields are plain values: `title: 'My Instrument'` +- **Multilingual**: `language: ['en', 'fr']` + - All UI string fields become per-language objects: `title: { en: 'My Instrument', fr: 'Mon instrument' }` + +The only two supported languages are `'en'` (English) and `'fr'` (French). + +```ts +type Language = 'en' | 'fr'; +type InstrumentKind = 'FILE' | 'FORM' | 'INTERACTIVE' | 'SERIES'; +type InstrumentLanguage = Language | Language[]; +type InstrumentUIOption = TLanguage extends Language + ? TValue + : TLanguage extends Language[] + ? { [K in TLanguage[number]]: TValue } + : never; +``` + +All instruments share a common base structure: + +```ts +interface InstrumentDetails { + /** The legal person(s) that created the instrument and hold copyright to the instrument */ + authors?: null | string[]; + + /** A brief description of the instrument, such as the purpose and history of the instrument */ + description: InstrumentUIOption; + + /** + * @deprecated use `clientDetails.estimatedDuration`. + * An integer representing the estimated number of minutes to complete the instrument. + */ + estimatedDuration?: number; + + /** + * @deprecated use `clientDetails.instructions`. + * Brief sequential instructions for how the subject should complete the instrument. + */ + instructions?: InstrumentUIOption; + + /** + * A valid SPDX license identifier (`LicenseIdentifier`), corresponding to the SPDX license list + * version d2709ad (released on 2024-01-30). When authoring inside the Open Data Capture + * repository, this is further narrowed to an `ApprovedLicense` (e.g. `'Apache-2.0'`). + */ + license: LicenseIdentifier; + + /** An reference link where the user can learn more about the instrument */ + referenceUrl?: null | string; + + /** A URL where the user can find the source code for the instrument */ + sourceUrl?: null | string; + + /** The title of the instrument in the language it is written, omitting the definite article. */ + title: InstrumentUIOption; +} + +interface ClientInstrumentDetails { + /** An integer representing the estimated number of minutes for the average target subject to complete the instrument */ + estimatedDuration?: number; + + /** Brief sequential instructions for how the subject should complete the instrument. */ + instructions?: InstrumentUIOption; + + /** The title of the instrument to show the client. If not specified, defaults to `details.title` */ + title?: InstrumentUIOption; +} + +interface BaseInstrument { + /** The runtime version for this instrument; set automatically by `defineInstrument`. Do not set it yourself. */ + __runtimeVersion: 1; + /** The content in the instrument to be rendered to the client */ + clientDetails?: ClientInstrumentDetails; + /** The content in the instrument to be rendered to the clinician/researcher */ + content?: unknown; + /** The details of the instrument to be displayed to the user */ + details: InstrumentDetails; + /** The database ID for the instrument. For scalar instruments, this is derived from `internal`. */ + id?: string; + /** The discriminator key for the type of instrument */ + kind: InstrumentKind; + /** The language(s) in which the instrument is written */ + language: TLanguage; + /** A list of tags that users can use to filter instruments */ + tags: InstrumentUIOption; +} +``` + +> `__runtimeVersion` is injected by `defineInstrument` / `defineSeriesInstrument` — author your +> definition object without it. + +### 1.1 Scalar vs Series Instruments + +Scalar instruments are those that can be completed. They: + +- Have a stable `internal` identity (`edition` + `name`) +- Define a `validationSchema` (Zod v3 or v4) +- Produce one output payload (`data`) whose static type is derived from the schema output type +- Can define `measures` derived from that output + +```ts +type InstrumentMeasureVisibility = 'hidden' | 'visible'; + +// The value a measure may resolve to. +type InstrumentMeasureValue = boolean | Date | number | RecordArrayFieldValue[] | string | undefined; + +interface ComputedInstrumentMeasure { + /** @deprecated use `visibility` */ + hidden?: boolean; + kind: 'computed'; + label: InstrumentUIOption; + value: (data: TData) => InstrumentMeasureValue; + visibility?: InstrumentMeasureVisibility; +} + +interface ConstantInstrumentMeasure { + /** @deprecated use `visibility` */ + hidden?: boolean; + kind: 'const'; + label?: InstrumentUIOption; + /** The data key whose value is exposed as a measure (must hold an `InstrumentMeasureValue`). */ + ref: Extract; + visibility?: InstrumentMeasureVisibility; +} + +type InstrumentMeasure = + | ComputedInstrumentMeasure + | ConstantInstrumentMeasure; + +type InstrumentMeasures = { + [key: string]: InstrumentMeasure; +}; + +interface ScalarInstrumentInternal { + edition: number; + name: string; +} + +interface ScalarInstrument extends BaseInstrument { + defaultMeasureVisibility?: InstrumentMeasureVisibility; + internal: ScalarInstrumentInternal; + measures: InstrumentMeasures | null; + validationSchema: z.ZodType; +} +``` + +Series instruments are orchestration containers. They: + +- Define an ordered list of scalar instrument identities (the same `{ edition, name }` shape used by scalar `internal`) +- Do not define `validationSchema`, `measures`, or `internal` (they are not directly completed) + +The `content` may be either a bare ordered array of identities, or an object that wraps the array +with optional series-level `params`: + +```ts +type SeriesContent = + | ScalarInstrumentInternal[] + | { items: ScalarInstrumentInternal[]; params?: { skipProgress?: boolean } }; + +interface SeriesInstrument extends BaseInstrument { + content: SeriesContent; + internal?: never; + kind: 'SERIES'; +} +``` + +### 1.2 Scalar Instrument Kinds + +#### 1.3.1 Form Instruments + +A form instrument (`kind: 'FORM'`) is a **scalar** instrument with declarative, data-driven content. The validation schema defines the output data shape, and the `content` describes corresponding UI fields. + +There are two authoring styles supported for `content`: + +1. Flat: A direct object mapping from every data key to its field definition +2. Grouped: An array of objects, each containing a subset of fields + +Use flat when all fields belong to a single, unsectioned form. Use grouped when fields should be visually organized into named sections. + +Every key present in the `validationSchema` object must have a corresponding entry in content object (in the case of a flat structure), or should appear in exactly one group across the full array (in the case of a grouped structure). + +##### Data Value Types + +The `Data` type is a flat record whose values are drawn from a two-level hierarchy of field value types: + +```ts +// Scalar values +type ScalarFieldValue = boolean | Date | number | Set | string | undefined; + +// Composite values (contain nested scalar values) +type FieldsetValue = { [key: string]: ScalarFieldValue }; // one row +type RecordArrayFieldValue = FieldsetValue[] | undefined; // array of rows +type NumberRecordFieldValue = Partial<{ [key: string]: number }> | undefined; // keyed numeric scores + +type CompositeFieldValue = NumberRecordFieldValue | RecordArrayFieldValue; +type FieldValue = CompositeFieldValue | ScalarFieldValue; + +// The top-level data record +type Data = { [key: string]: FieldValue }; +``` + +The `validationSchema` must produce an object whose keys map to these value types. The TypeScript type of the instrument's `TData` generic is inferred from the schema's output type. + +##### Static Scalar Fields + +Every scalar field extends `BaseField`: + +```ts +interface BaseField { + description?: InstrumentUIOption; + disabled?: boolean; + kind: StaticFieldKind; + label: InstrumentUIOption; +} +``` + +The five static scalar field types are: + +**`string`** - corresponds to type `string` + +```ts +// With a fixed set of options (radio buttons or a dropdown) +interface StringFieldWithOptions extends BaseField { + kind: 'string'; + options: { [K in TValue]: InstrumentUIOption }; // maps each valid value to its display label + variant: 'radio' | 'select'; +} + +// Free-text input +interface StringFieldWithoutOptions extends BaseField { + kind: 'string'; + placeholder?: string; // placeholder is plain text, not multilingual + variant: 'input' | 'textarea'; +} + +// Masked password input, with an optional strength meter +interface StringFieldPassword extends BaseField { + kind: 'string'; + // returns a strength score from 0 (weakest) to 4 (strongest) + calculateStrength?: (this: void, password: string) => 0 | 1 | 2 | 3 | 4; + variant: 'password'; +} +``` + +**`number`** - corresponds to type `number` + +```ts +// With a fixed set of options (radio buttons or a dropdown) +interface NumberFieldWithOptions extends BaseField { + disableAutoPrefix?: boolean; + kind: 'number'; + options: { [K in TValue]: string }; + variant: 'radio' | 'select'; +} + +// Plain numeric input +interface NumberFieldInput extends BaseField { + kind: 'number'; + /** @deprecated define the bound in the `validationSchema` instead, for better user feedback */ + max?: number; + /** @deprecated define the bound in the `validationSchema` instead, for better user feedback */ + min?: number; + variant: 'input'; +} + +// Range slider +interface NumberFieldSlider extends BaseField { + kind: 'number'; + max: number; + min: number; + variant: 'slider'; +} +``` + +**`date`** - corresponds to type `Date` + +```ts +interface DateField extends BaseField { + kind: 'date'; +} +``` + +**`boolean`** - corresponds to type `boolean` + +```ts +interface BooleanFieldCheckbox extends BaseField { + kind: 'boolean'; + variant: 'checkbox'; +} + +interface BooleanFieldRadio extends BaseField { + kind: 'boolean'; + // define custom display labels for true or false + options?: InstrumentUIOption; + variant: 'radio'; +} +``` + +**`set`** — Represents a multi-select value stored as `Set`: + +```ts +interface SetField> extends BaseField { + kind: 'set'; + options: InstrumentUIOption; // TItem = string members of TValue + variant: 'listbox' | 'select'; +} +``` + +##### Static Composite Fields + +Composite fields hold structured sub-values rather than a single value. + +**`record-array`** — An array of uniform rows, each row defined by a `Fieldset`. A `Fieldset` is an object that maps each key in the row to either a static scalar field or a `DynamicFieldsetField` (a field whose presence or definition depends on the current row's partial values): + +```ts +type DynamicFieldsetField = { + kind: 'dynamic'; + render: (this: void, fieldset: Partial) => null | ScalarField; +}; + +type Fieldset = { + [K in keyof TFieldset]: + | DynamicFieldsetField + | ScalarField; +}; + +interface RecordArrayField extends BaseField { + /** When true, suppress the automatic numeric suffix appended to each row's heading */ + disableAutoSuffix?: boolean; + fieldset: Fieldset; + kind: 'record-array'; +} +``` + +**`number-record`** — A fixed set of labeled rows each producing a numeric score, rendered as a Likert-style grid. The `items` object defines the label and optional description for each row key; the `options` object defines the shared column headers (numeric value → display string): + +```ts +interface NumberRecordField extends BaseField { + /** When true, suppress the automatic numeric prefix shown before each option label */ + disableAutoPrefix?: boolean; + items: { + [K in keyof TValue]: { + description?: InstrumentUIOption; + label: InstrumentUIOption; + }; + }; + kind: 'number-record'; + options: InstrumentUIOption; + variant: 'likert'; +} +``` + +##### Dynamic Fields + +A top-level dynamic field conditionally renders a static field based on the form's current (possibly partial) data. It is used for cross-field conditional logic (e.g., show field B only when field A has a specific value): + +```ts +type DynamicField = { + deps: readonly Extract[]; // keys this field depends on + kind: 'dynamic'; + render: (this: void, data: PartialData) => null | StaticField; +}; +``` + +- `deps` lists the data keys the render function reads. The field is re-evaluated whenever any listed dependency changes. +- `render` receives the current partial form data and must return either a `StaticField` definition (to show the field) or `null` (to hide it and clear its value). + +##### Content Structure + +```ts +type Fields = { + [K in keyof TData]-?: UnknownField; +}; + +type FieldsGroup = { + description?: InstrumentUIOption; + fields: { [K in keyof TData]?: UnknownField }; + title?: InstrumentUIOption; +}; + +type Content = Fields | FieldsGroup[]; +``` + +##### Full Type + +```ts +type FormInstrument = ScalarInstrument< + TData, + TLanguage +> & { + content: FormInstrument.Content; + initialValues?: PartialDeep; + kind: 'FORM'; + measures: InstrumentMeasures | null; +}; +``` + +- `initialValues` — optional pre-populated values, applied when the form is first rendered. This is a _deep_ partial (`PartialDeep`), so nested composite values (e.g. individual fields within `record-array` rows) may be partially specified. + +#### 1.3.2 Interactive Instruments + +An interactive instrument (`kind: 'INTERACTIVE'`) is a **scalar** instrument that executes arbitrary code inside a sandboxed iframe. + +##### Data Type + +The output data type is not constrained to a schema-derived shape. Instead, `TData` must be assignable to the recursive `Json` type: + +```ts +type JsonLiteral = boolean | null | number | string; +type Json = Json[] | JsonLiteral | { [key: string]: Json }; + +type Data = Json; +``` + +The `validationSchema` must still be provided (as on all scalar instruments) and must produce a type that satisfies `Json`. + +##### Content Object + +The `content` property is a plain object with the following fields: + +```ts +content: { + /** The entry point for the task. Must call `done` exactly once when the task is complete. */ + render: (done: (data: TData) => void) => PromiseLike | void; + + /** Raw HTML string to inject into the iframe */ + html?: string; + + /** tags for the iframe ; each key is the meta name, each value is the content */ + meta?: { [name: string]: string }; + + /** Static asset paths (e.g., '/image.png') mapped to base64 data URLs for use by legacy scripts */ + staticAssets?: { [key: string]: string }; + + /** Enter fullscreen automatically when the instrument content is shown */ + defaultFullscreen?: boolean; + + /** Show an initial screen to select a language before the instrument begins */ + enableLanguageSelect?: boolean; + + /** Show a button above the instrument to change languages */ + enableLanguageToggle?: boolean; + + /** Block the user from changing languages during the instrument */ + enableLanguageLock?: boolean; + + /** Inject pre-compiled legacy assets into the iframe (for bundles that cannot use the runtime module system) */ + readonly __injectHead?: { + /** base64-encoded legacy scripts */ + readonly scripts?: readonly string[]; + /** base64-encoded css */ + readonly style?: string; + }; +} +``` + +- `render` is the only required field in `content`. It receives a `done` callback and must call it exactly once with the collected data to end the task and submit the result. +- `staticAssets` maps virtual paths to base64 data URLs so that legacy scripts referencing asset paths (e.g., image `src` attributes) continue to work inside the iframe sandbox. +- `__injectHead` is intended for instruments that wrap pre-compiled legacy JavaScript bundles that cannot be refactored to use the runtime module system; in most cases prefer the import forms in §4.2 (`import './legacy.js?legacy'`, `import './styles.css'`) over hand-encoding base64. +- The `enableLanguage*` options only apply to multilingual instruments (`language: ['en', 'fr']`). + +##### Full Type + +```ts +type InteractiveInstrument< + TData extends InteractiveInstrument.Data, + TLanguage extends InstrumentLanguage +> = ScalarInstrument & { + content: { + render: (done: (data: TData) => void) => PromiseLike | void; + html?: string; + meta?: { [name: string]: string }; + staticAssets?: { [key: string]: string }; + defaultFullscreen?: boolean; + enableLanguageSelect?: boolean; + enableLanguageToggle?: boolean; + enableLanguageLock?: boolean; + readonly __injectHead?: { + readonly scripts?: readonly string[]; + readonly style?: string; + }; + }; + kind: 'INTERACTIVE'; +}; +``` + +#### 1.3.3 File Instruments + +A file instrument (`kind: 'FILE'`) is a **scalar** instrument that collects one or more uploaded files from the subject rather than declarative field data. It still requires a `validationSchema`, but its own `Data` type is empty (`{ [key: string]: never }`) — the files themselves are the payload, not field values. + +The `content` describes the file slots the subject must fill, grouped into one or more `fileGroups`: + +```ts +namespace FileInstrument { + type Data = { [key: string]: never }; + + type FileGroup = { + /** The base filename the uploaded file(s) will be stored under */ + basename: string; + /** The minimum and maximum number of files accepted for this group */ + count: { max: number; min: number }; + /** The label shown to the user for this group */ + label: InstrumentUIOption; + /** The single accepted MIME type, or `null` to accept any type */ + type: FileType | null; + }; + + type Content = { + fileGroups: FileGroup[]; + }; +} + +type FileInstrument = ScalarInstrument & { + content: FileInstrument.Content; + kind: 'FILE'; +}; +``` + +`FileType` is a single MIME string drawn from the predefined `FILE_TYPES` groups: + +- **binary**: `application/octet-stream` +- **documents**: `application/pdf`, `text/plain`, `text/markdown`, `text/html`, `application/msword`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, `application/rtf` +- **images**: `image/png`, `image/jpeg`, `image/tiff`, `image/gif`, `image/svg+xml`, `image/bmp` +- **spreadsheets**: `text/csv`, `text/tsv`, `application/vnd.ms-excel`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `application/vnd.oasis.opendocument.spreadsheet` +- **structured**: `application/json`, `application/xml` + +Because `FileInstrument.Data` is empty, the `validationSchema` should validate to an empty object (e.g. `z.object({})`) and `measures` is typically `null`. + +## 3. Runtime API and Imports + +### 3.1 Available Runtime Modules + +All imports must use the runtime URL format: `/runtime/v1/@`. + +The `validationSchema` may be authored with **either Zod v3 or Zod v4** (`InstrumentValidationSchema` accepts `z.ZodType` from either). Choose the import path accordingly: + +```ts +import { z } from '/runtime/v1/zod@3.x'; // Zod v3 API +import { z } from '/runtime/v1/zod@3.x/v4'; // Zod v4 API +``` + +Prefer one consistently within a single instrument. New instruments should use the v4 API. + +### 3.2 The Define Instrument API + +The `defineInstrument` function must always be used to create scalar (`FORM`, `INTERACTIVE`, and `FILE`) instruments. It accepts a definition object containing all required fields for the chosen `kind`, and returns a fully typed instrument object — including the `__runtimeVersion` field, which it sets automatically. The output data type is derived automatically from the `validationSchema`. + +```ts +import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; +import { z } from '/runtime/v1/zod@3.x/v4'; +``` + +The `defineSeriesInstrument` function must always be used to create `SERIES` instruments. + +```ts +import { defineSeriesInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; +``` + +### 3.3 Runtime Helpers + +`@opendatacapture/runtime-core` also exports helpers usable from within an instrument's `render`: + +- **`addNotification(notification)`** — display a notification in the ODC UI during an **interactive** instrument (does not work in forms): + + ```ts + import { addNotification } from '/runtime/v1/@opendatacapture/runtime-core'; + + addNotification({ + type: 'success', // 'error' | 'info' | 'success' | 'warning' + title: 'Saved', + message: 'Your response was recorded.', + variant: 'standard' // 'critical' | 'standard' + }); + ``` + +- **Translators** — `Translator`, `StandaloneTranslator`, and `SynchronizedTranslator` provide message translation for multilingual interactive instruments. Construct with `{ translations, fallbackLanguage? }`, then `init()`, read `resolvedLanguage`, switch with `changeLanguage(lang)`, and resolve keys with `t(key)`: + + ```ts + import { Translator } from '/runtime/v1/@opendatacapture/runtime-core'; + + const translator = new Translator({ + fallbackLanguage: 'en', + translations: { greeting: { en: 'Hello', fr: 'Bonjour' } } + }); + translator.init(); + translator.t('greeting'); + ``` + +- **`asSnakeCase(obj)`** — returns a copy of `obj` with its keys converted to snake_case. + +## 4. File Structure + +### 4.1 Entrypoint + +The bundler identifies the instrument entrypoint by searching for (in order): + +1. `index.tsx` +2. `index.jsx` +3. `index.ts` +4. `index.js` + +The entrypoint must have a **default export** of the instrument object (returned by `defineInstrument` or `defineSeriesInstrument`). + +### 4.2 Import Types + +- **Runtime imports** (use URL format): `import { z } from '/runtime/v1/zod@3.x'` + - Resolved by the ODC runtime. +- **Relative imports**: `import { helper } from './helper.ts'` + - Must exist within the instrument's file set; bundled into the output. +- **CSS imports**: `import './styles.css'` + - Bundled and automatically injected into the iframe head before `render` is called. +- **Image/asset imports**: `import logoSrc from './logo.png'` + - Bundled as base64 data URL strings. +- **HTML imports**: `import html from './index.html'` + - Bundled as a string. +- **Legacy script imports**: `import './legacy.js?legacy'` + - Processed as non-strict mode scripts and automatically injected into the iframe head. + +### 4.3 Single-file vs Multi-file + +For simple instruments, a single `index.ts` file is sufficient. For complex instruments, multiple files may be used: + +- Split React components into separate `.tsx` files. +- Separate translation helpers, utility functions, etc. +- Import CSS, images, HTML, and legacy scripts as needed. + +## 14. Complete Examples + +### 14.1 Comprehensive Form Instrument Example + +This example demonstrates: multilingual support, grouped content, all common field types, dynamic fields, cross-field validation, computed measures, and constant measures. + +```ts +import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; +import { z } from '/runtime/v1/zod@3.x/v4'; + +export default defineInstrument({ + kind: 'FORM', + language: ['en', 'fr'], + internal: { + edition: 1, + name: 'COMPREHENSIVE_FORM_EXAMPLE' + }, + tags: { + en: ['Demo', 'Reference'], + fr: ['Démo', 'Référence'] + }, + details: { + title: { en: 'Comprehensive Form Example', fr: 'Exemple de formulaire complet' }, + description: { + en: 'Demonstrates every form field type and common patterns.', + fr: 'Démontre chaque type de champ et les modèles courants.' + }, + license: 'Apache-2.0', + authors: ['Example Author'] + }, + clientDetails: { + estimatedDuration: 10, + instructions: { + en: ['Please answer all required questions.', 'Conditional questions will appear based on your answers.'], + fr: [ + 'Veuillez répondre à toutes les questions obligatoires.', + 'Des questions conditionnelles apparaîtront selon vos réponses.' + ] + } + }, + content: [ + { + title: { en: 'Basic Fields', fr: 'Champs de base' }, + description: { + en: 'All basic scalar field types', + fr: 'Tous les types de champs scalaires de base' + }, + fields: { + agreedToTerms: { + kind: 'boolean', + label: { en: 'I agree to the terms', fr: "J'accepte les conditions" }, + variant: 'checkbox' + }, + currentlyEmployed: { + kind: 'boolean', + label: { en: 'Are you currently employed?', fr: 'Êtes-vous actuellement employé(e)?' }, + variant: 'radio', + options: { + en: { true: 'Yes', false: 'No' }, + fr: { true: 'Oui', false: 'Non' } + } + }, + dateOfBirth: { + kind: 'date', + label: { en: 'Date of Birth', fr: 'Date de naissance' } + }, + overallHappiness: { + kind: 'number', + label: { en: 'Overall Happiness', fr: 'Bonheur général' }, + description: { + en: 'Rate from 1 (very unhappy) to 10 (very happy)', + fr: 'Évaluez de 1 (très malheureux) à 10 (très heureux)' + }, + variant: 'slider', + min: 1, + max: 10 + }, + educationLevel: { + kind: 'number', + label: { en: 'Education Level', fr: "Niveau d'études" }, + variant: 'select', + options: { + en: { 1: 'Primary', 2: 'Secondary', 3: 'Post-secondary' }, + fr: { 1: 'Primaire', 2: 'Secondaire', 3: 'Postsecondaire' } + } + }, + firstName: { + kind: 'string', + label: { en: 'First Name', fr: 'Prénom' }, + variant: 'input' + }, + preferredLanguage: { + kind: 'string', + label: { en: 'Preferred Language', fr: 'Langue préférée' }, + variant: 'radio', + options: { + en: { en: 'English', fr: 'French' }, + fr: { en: 'Anglais', fr: 'Français' } + } + }, + additionalComments: { + kind: 'string', + label: { en: 'Additional Comments', fr: 'Commentaires supplémentaires' }, + variant: 'textarea' + }, + preferredActivities: { + kind: 'set', + label: { en: 'Preferred Activities', fr: 'Activités préférées' }, + variant: 'listbox', + options: { + en: { reading: 'Reading', sports: 'Sports', cooking: 'Cooking' }, + fr: { reading: 'Lecture', sports: 'Sports', cooking: 'Cuisine' } + } + } + } + }, + { + title: { en: 'Composite Fields', fr: 'Champs composites' }, + fields: { + weeklyMoodRatings: { + kind: 'number-record', + label: { en: 'Weekly Mood Ratings', fr: "Évaluations d'humeur hebdomadaires" }, + variant: 'likert', + items: { + monday: { label: { en: 'Monday', fr: 'Lundi' } }, + wednesday: { label: { en: 'Wednesday', fr: 'Mercredi' } }, + friday: { label: { en: 'Friday', fr: 'Vendredi' } } + }, + options: { + en: { 1: 'Poor', 2: 'Fair', 3: 'Good', 4: 'Excellent' }, + fr: { 1: 'Mauvaise', 2: 'Passable', 3: 'Bonne', 4: 'Excellente' } + } + }, + medications: { + kind: 'record-array', + label: { en: 'Current Medications', fr: 'Médicaments actuels' }, + fieldset: { + medicationName: { + kind: 'string', + label: { en: 'Medication Name', fr: 'Nom du médicament' }, + variant: 'input' + }, + dosageMg: { + kind: 'number', + label: { en: 'Dosage (mg)', fr: 'Dosage (mg)' }, + variant: 'input' + } + } + } + } + }, + { + title: { en: 'Conditional Questions', fr: 'Questions conditionnelles' }, + fields: { + hasChronicCondition: { + kind: 'boolean', + label: { en: 'Do you have a chronic condition?', fr: 'Avez-vous une maladie chronique?' }, + variant: 'radio', + options: { + en: { true: 'Yes', false: 'No' }, + fr: { true: 'Oui', false: 'Non' } + } + }, + chronicConditionName: { + kind: 'dynamic', + deps: ['hasChronicCondition'], + render(data) { + if (!data?.hasChronicCondition) { + return null; + } + return { + kind: 'string', + label: { en: 'Condition Name', fr: 'Nom de la condition' }, + variant: 'input', + isRequired: true + }; + } + } + } + } + ], + measures: { + overallHappiness: { + kind: 'const', + ref: 'overallHappiness', + label: { en: 'Overall Happiness', fr: 'Bonheur général' }, + visibility: 'visible' + }, + educationLevel: { + kind: 'const', + ref: 'educationLevel' + }, + happinessCategory: { + kind: 'computed', + label: { en: 'Happiness Category', fr: 'Catégorie de bonheur' }, + value(data) { + if (data.overallHappiness <= 3) return 'Low'; + if (data.overallHappiness <= 6) return 'Moderate'; + return 'High'; + }, + visibility: 'visible' + } + }, + defaultMeasureVisibility: 'hidden', + validationSchema: z + .object({ + agreedToTerms: z.boolean(), + currentlyEmployed: z.boolean(), + dateOfBirth: z.date(), + overallHappiness: z.number().int().min(1).max(10), + educationLevel: z.union([z.literal(1), z.literal(2), z.literal(3)]), + firstName: z.string(), + preferredLanguage: z.enum(['en', 'fr']), + additionalComments: z.string().optional(), + preferredActivities: z.set(z.enum(['reading', 'sports', 'cooking'])), + weeklyMoodRatings: z.record( + z.enum(['monday', 'wednesday', 'friday']), + z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]) + ), + medications: z.array( + z.object({ + medicationName: z.string(), + dosageMg: z.number() + }) + ), + hasChronicCondition: z.boolean(), + chronicConditionName: z.string().optional() + }) + .superRefine((data, ctx) => { + if (data.hasChronicCondition && !data.chronicConditionName) { + ctx.addIssue({ + code: 'custom', + path: ['chronicConditionName'], + message: 'This field is required / Ce champ est obligatoire.' + }); + } + }) +}); +``` + +--- + +### 14.2 Comprehensive Interactive Instrument Example + +This example demonstrates an interactive instrument. + +```ts +import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; +import { z } from '/runtime/v1/zod@3.x/v4'; + +export default defineInstrument({ + kind: 'INTERACTIVE', + language: 'en', + tags: [''], + internal: { + edition: 1, + name: '' + }, + content: { + render(done) { + const button = document.createElement('button'); + button.textContent = 'Submit Instrument'; + document.body.appendChild(button); + button.addEventListener('click', () => { + done({ message: 'Hello World' }); + }); + } + }, + clientDetails: { + estimatedDuration: 1, + instructions: [''] + }, + details: { + description: '', + license: 'Apache-2.0', + title: '' + }, + measures: {}, + validationSchema: z.object({ + message: z.string() + }) +}); +``` diff --git a/packages/instrument-guidelines/README.md b/packages/instrument-guidelines/README.md new file mode 100644 index 000000000..21a371cfc --- /dev/null +++ b/packages/instrument-guidelines/README.md @@ -0,0 +1,48 @@ +# @opendatacapture/instrument-guidelines + +Guidelines for authoring [Open Data Capture](https://opendatacapture.org) instruments, +written to be read by an AI agent (e.g. Claude Code). Install this package and link the +guidelines into your project, then point your agent at the resulting file — it will read +the full instrument specification and author instruments accordingly. + +## Usage + +Install as a dev dependency: + +```bash +pnpm add -D @opendatacapture/instrument-guidelines +``` + +Link the guidelines into your project root: + +```bash +pnpm instrument-guidelines +``` + +This creates an `AGENTS.md` symlink pointing at the installed guidelines, so it always +reflects the version you have installed — updating the package updates the guidelines. + +### Options + +| Option | Description | +| --------------- | ------------------------------------------------------------- | +| `--file ` | Target filename to create (default: `AGENTS.md`). Repeatable. | +| `--copy` | Copy the guidelines instead of symlinking them. | +| `--force` | Overwrite existing target file(s). | +| `-h`, `--help` | Show help. | + +Create both `AGENTS.md` and a `CLAUDE.md` (which Claude Code auto-loads): + +```bash +pnpm instrument-guidelines --file AGENTS.md --file CLAUDE.md +``` + +> **Note:** Install the package first (as above) so the symlink targets the stable copy +> in `node_modules`. For a one-off run via `pnpm dlx` without installing, use `--copy`, +> since the ephemeral install location would otherwise leave a dangling link. + +## Pointing your agent at it + +Once a `CLAUDE.md` (or `AGENTS.md`) exists at your project root, start Claude Code in that +directory — it reads the guidelines automatically. No further setup is required to begin +authoring instruments. diff --git a/packages/instrument-guidelines/bin/cli.mjs b/packages/instrument-guidelines/bin/cli.mjs new file mode 100755 index 000000000..bc3525b15 --- /dev/null +++ b/packages/instrument-guidelines/bin/cli.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const source = path.join(packageRoot, 'AGENTS.md'); + +const HELP = `Usage: instrument-guidelines [options] + +Links the Open Data Capture instrument-authoring guidelines into the current +directory so an agent (e.g. Claude Code) can read them. + +Options: + --file Target filename to create (default: AGENTS.md). Repeatable, + e.g. --file AGENTS.md --file CLAUDE.md to create both. + --copy Copy the guidelines instead of symlinking them. + --force Overwrite existing target file(s). + -h, --help Show this help. +`; + +function fail(message) { + process.stderr.write(`error: ${message}\n`); + process.exit(1); +} + +let parsed; +try { + parsed = parseArgs({ + options: { + copy: { default: false, type: 'boolean' }, + file: { multiple: true, type: 'string' }, + force: { default: false, type: 'boolean' }, + help: { default: false, short: 'h', type: 'boolean' } + } + }); +} catch (err) { + fail(err.message); +} + +const { copy, file, force, help } = parsed.values; + +if (help) { + process.stdout.write(HELP); + process.exit(0); +} + +if (!fs.existsSync(source)) { + fail(`could not find guidelines at ${source}`); +} + +const targets = file && file.length > 0 ? file : ['AGENTS.md']; +const cwd = process.cwd(); + +for (const name of targets) { + const target = path.resolve(cwd, name); + + if (fs.lstatSync(target, { throwIfNoEntry: false })) { + if (!force) { + process.stdout.write(`skipped ${name} (already exists; pass --force to overwrite)\n`); + continue; + } + fs.rmSync(target, { force: true }); + } + + if (copy) { + fs.copyFileSync(source, target); + process.stdout.write(`copied ${name}\n`); + continue; + } + + const linkTarget = path.relative(path.dirname(target), source); + try { + fs.symlinkSync(linkTarget, target); + process.stdout.write(`linked ${name} -> ${linkTarget}\n`); + } catch (err) { + if (err.code === 'EPERM') { + fail(`cannot create symlink (${err.code}); re-run with --copy instead`); + } + fail(err.message); + } +} diff --git a/packages/instrument-guidelines/package.json b/packages/instrument-guidelines/package.json new file mode 100644 index 000000000..8c0b5aad2 --- /dev/null +++ b/packages/instrument-guidelines/package.json @@ -0,0 +1,29 @@ +{ + "name": "@opendatacapture/instrument-guidelines", + "type": "module", + "version": "0.0.1", + "description": "Guidelines for authoring Open Data Capture instruments, intended to be read by an AI agent (e.g. Claude Code).", + "license": "Apache-2.0", + "keywords": [ + "opendatacapture", + "instrument", + "agent", + "guidelines", + "claude" + ], + "exports": { + "./AGENTS.md": "./AGENTS.md", + "./package.json": "./package.json" + }, + "bin": { + "instrument-guidelines": "./bin/cli.mjs" + }, + "files": [ + "bin", + "AGENTS.md" + ], + "scripts": { + "format": "prettier --write bin", + "lint": "eslint --fix bin" + } +} From bd55c0fd32950571bf0e96b9157eea4abf5b6fd5 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Tue, 2 Jun 2026 12:35:43 -0400 Subject: [PATCH 2/3] add increment-version script --- scripts/increment-version.sh | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100755 scripts/increment-version.sh diff --git a/scripts/increment-version.sh b/scripts/increment-version.sh new file mode 100755 index 000000000..07dc0a0d7 --- /dev/null +++ b/scripts/increment-version.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -euo pipefail +IFS=$'\n\t' + +[ "${BASH_VERSINFO:-0}" -ge 5 ] || (echo "Error: Bash >= 5.0 is required for this script" >&2 && exit 1) + +projectRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd .. && pwd)" + +# package.json files (relative to projectRoot) kept in sync with the root version +packages=( + "package.json" + "runtime/v1/package.json" + "packages/instrument-bundler/package.json" + "packages/serve-instrument/package.json" + "packages/instrument-guidelines/package.json" +) + +currentVersion=$(node -e 'process.stdout.write(require(process.argv[1]).version)' "$projectRoot/package.json") +IFS='.' read -r major minor patch <<< "$currentVersion" +IFS=$'\n\t' + +echo "Current version: $currentVersion" +PS3="Select a version bump: " +select bump in major minor patch quit; do + case "${bump:-}" in + major) newVersion="$((major + 1)).0.0"; break ;; + minor) newVersion="$major.$((minor + 1)).0"; break ;; + patch) newVersion="$major.$minor.$((patch + 1))"; break ;; + quit) echo "Aborted."; exit 0 ;; + *) echo "Invalid selection." ;; + esac +done + +read -r -p "Bump $currentVersion -> $newVersion? [y/N] " confirm +case "$confirm" in + [yY]*) ;; + *) echo "Aborted."; exit 0 ;; +esac + +for pkg in "${packages[@]}"; do + file="$projectRoot/$pkg" + node -e ' + const fs = require("fs"); + const [file, version] = process.argv.slice(1); + const contents = fs.readFileSync(file, "utf8"); + const updated = contents.replace(/("version":\s*)"[^"]*"/, `$1"${version}"`); + if (updated === contents) { + console.error(`Error: no version field updated in ${file}`); + process.exit(1); + } + fs.writeFileSync(file, updated); + ' "$file" "$newVersion" + echo "Updated $pkg -> $newVersion" +done + +echo "Done! All packages set to $newVersion" From 25abe018345bd9527a8e2dcaf69b20f67ed02693 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Tue, 2 Jun 2026 12:35:57 -0400 Subject: [PATCH 3/3] inline release workflow to prepare for changes --- .github/scripts/release.cjs | 62 ++++++++++++++++++++ .github/workflows/release.yaml | 104 ++++++++++++++++++++++++++++++--- 2 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/release.cjs diff --git a/.github/scripts/release.cjs b/.github/scripts/release.cjs new file mode 100644 index 000000000..ecbcee036 --- /dev/null +++ b/.github/scripts/release.cjs @@ -0,0 +1,62 @@ +const fs = require('node:fs'); + +const VERSION_TAG_REGEX = /^v(\d+\.\d+\.\d+(-(alpha|beta)\.\d+)?)$/; + +const ORG = 'DouglasNeuroinformatics'; +const PACKAGES = ['open-data-capture-api', 'open-data-capture-gateway', 'open-data-capture-web']; + +/** @typedef {import('github-script').AsyncFunctionArguments} AsyncFunctionArguments */ + +/** @typedef {Awaited>['data'][number]} PackageInfo */ + +/** + * Extract the semver release tag (e.g. '1.2.3') for a published package version, or null + * @param {PackageInfo} packageInfo + * @returns {null | string} + */ +function extractPackageVersionTag(packageInfo) { + const tags = packageInfo.metadata?.container?.tags ?? []; + for (const tag of tags) { + const match = VERSION_TAG_REGEX.exec(tag)?.[1]; + if (match) { + return match; + } + } + return null; +} + +/** + * Determine whether the current package.json version differs from the latest published + * container release, and expose the version + decision as workflow outputs. + * @param {AsyncFunctionArguments} args + */ +module.exports = async function main({ core, github }) { + /** @type {string} */ + const currentVersion = JSON.parse(fs.readFileSync('package.json', 'utf-8')).version; + + /** @type {string | null | undefined} */ + let resolvedReleaseVersionTag = undefined; + + for (const packageName of PACKAGES) { + const response = await github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({ + org: ORG, + package_name: packageName, + package_type: 'container' + }); + const latestPackage = response.data.find((item) => item.metadata?.container?.tags?.includes('latest')); + if (!latestPackage) { + throw new Error(`Failed to find package '${packageName}' with tag 'latest'`); + } + const versionTag = extractPackageVersionTag(latestPackage); + if (resolvedReleaseVersionTag === undefined) { + resolvedReleaseVersionTag = versionTag; + } else if (versionTag !== resolvedReleaseVersionTag) { + throw new Error( + `Unexpected version for package '${packageName}': expected '${resolvedReleaseVersionTag}', got '${versionTag}'` + ); + } + } + + core.setOutput('version', currentVersion); + core.setOutput('should_release', String(currentVersion !== resolvedReleaseVersionTag)); +}; diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 22b048acd..b7225300c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,13 +3,101 @@ on: push: branches: ['main'] workflow_dispatch: +permissions: + contents: write + packages: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +defaults: + run: + shell: bash jobs: + configure: + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.version.outputs.should_release }} + version: ${{ steps.version.outputs.version }} + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Derive Build Matrix + id: matrix + run: | + matrix=$(docker compose config --no-interpolate --format json \ + | jq -c '{include:[.services|to_entries[]|select(.value.build and .value.image)|{image:(.value.image|split(":")[0]),dockerfile:.value.build.dockerfile}]}') + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + - name: Determine Release Version + id: version + uses: actions/github-script@v7 + with: + script: | + await require('./.github/scripts/release.cjs')({ context, core, github, exec, glob, io }) + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Setup PNPM + uses: pnpm/action-setup@v3 + - name: Setup Node + uses: actions/setup-node@v4 + with: + cache: pnpm + node-version-file: '.nvmrc' + - name: Install Dependencies + run: pnpm install --frozen-lockfile + - name: Validate + run: GATEWAY_DATABASE_URL=file:${TMPDIR}tmp.db pnpm lint + build: + runs-on: ubuntu-latest + needs: [configure, validate] + if: ${{ needs.configure.outputs.should_release == 'true' }} + strategy: + fail-fast: true + matrix: ${{ fromJSON(needs.configure.outputs.matrix) }} + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Set Up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set Up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract Metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ matrix.image }} + tags: | + type=raw,value=latest + type=raw,value=${{ needs.configure.outputs.version }} + - name: Build and Push Docker Images + uses: docker/build-push-action@v6 + with: + build-args: | + RELEASE_VERSION=${{ needs.configure.outputs.version }} + context: . + file: ${{ matrix.dockerfile }} + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} release: - permissions: - contents: write - packages: write - uses: DouglasNeuroinformatics/.github/.github/workflows/app-release.yaml@main - with: - org: DouglasNeuroinformatics - packages: open-data-capture-api, open-data-capture-gateway, open-data-capture-web - validate_command: GATEWAY_DATABASE_URL=file:${TMPDIR}tmp.db pnpm lint + runs-on: ubuntu-latest + needs: + - build + - configure + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.configure.outputs.version }}