diff --git a/.github/scripts/__tests__/capability-bundle-contract.test.js b/.github/scripts/__tests__/capability-bundle-contract.test.js new file mode 100644 index 000000000..22a5c018a --- /dev/null +++ b/.github/scripts/__tests__/capability-bundle-contract.test.js @@ -0,0 +1,215 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + computeCapabilityBundleHash, + loadCapabilityBundles, + renderCapabilityFragments, + selectCapabilityBundles, + validateCapabilityBundle, +} = require('../capability_bundle'); +const { composePrompt } = require('../keepalive_prompt_composer'); +const { buildMetricsRecord, parseCapabilityBundlesInput } = require('../keepalive_loop'); + +function validBundle(overrides = {}) { + const bundle = { + schema_version: 'capability-bundle/v1', + capability_id: 'keepalive/static-spa', + version: '1.0.0', + selector: { + repo: 'stranske/Inv-Man-Intake', + agent: 'codex', + mode: 'normal', + labels: ['agents:keepalive'], + }, + owner: 'stranske/Workflows', + fragments: { + task: 'Exercise the static SPA packet upload before claiming UI parity.', + acceptance: 'Report the frontend_verify gate ID and the offline-bundle assertion.', + }, + gates: ['frontend_verify@1', 'offline_bundle@1'], + playbooks: ['docs/keepalive/KEEPALIVE_TROUBLESHOOTING.md'], + expires_at: '2099-01-01T00:00:00Z', + rollback: 'Remove the bundle from the registry and rerun keepalive without prompt fragments.', + ...overrides, + }; + return { + ...bundle, + content_hash: overrides.content_hash || computeCapabilityBundleHash(bundle), + }; +} + +test('valid bundle passes content-hash and safety validation', () => { + assert.equal( + validateCapabilityBundle(validBundle(), { + knownCapabilities: ['keepalive/static-spa'], + now: new Date('2026-01-01T00:00:00Z'), + }), + true, + ); +}); + +test('hash mismatch blocks dispatch', () => { + const bundle = validBundle({ content_hash: 'sha256:deadbeef' }); + assert.throws( + () => validateCapabilityBundle(bundle, { knownCapabilities: ['keepalive/static-spa'] }), + /hash mismatch/, + ); +}); + +test('unknown capability id is rejected', () => { + const bundle = validBundle({ capability_id: 'local/unknown' }); + assert.throws( + () => validateCapabilityBundle(bundle, { knownCapabilities: ['keepalive/static-spa'] }), + /unknown capability id/, + ); +}); + +test('runtime validation matches schema patterns and top-level fields', () => { + assert.throws( + () => validateCapabilityBundle(validBundle({ capability_id: 'Keepalive Static SPA' })), + /invalid capability_id/, + ); + assert.throws( + () => validateCapabilityBundle(validBundle({ version: 'latest' })), + /invalid version/, + ); + assert.throws( + () => validateCapabilityBundle(validBundle({ local_control: 'reroute this run' })), + /unknown top-level fields: local_control/, + ); +}); + +test('missing required owner and rollback fields are rejected', () => { + assert.throws( + () => validateCapabilityBundle(validBundle({ owner: '' })), + /missing owner/, + ); + assert.throws( + () => validateCapabilityBundle(validBundle({ rollback: '' })), + /missing rollback/, + ); +}); + +test('unsafe inline prompt or credential fields are rejected', () => { + const bundle = validBundle({ + fragments: { + task: 'Exercise the static SPA packet upload before claiming UI parity.', + acceptance: 'Report the frontend_verify gate ID and the offline-bundle assertion.', + raw_prompt: 'do local hidden work', + }, + }); + assert.throws(() => validateCapabilityBundle(bundle), /unsafe inline fields: fragments.raw_prompt/); +}); + +test('unsafe command-style nested fields and blank gates are rejected', () => { + assert.throws( + () => validateCapabilityBundle(validBundle({ selector: { repo: 'stranske/Inv-Man-Intake', exec_command: 'run hidden command' } })), + /unsafe inline fields: selector.exec_command/, + ); + assert.throws( + () => validateCapabilityBundle(validBundle({ gates: ['frontend_verify@1', ''] })), + /at least one gate ref/, + ); +}); + +test('standalone bundle document loads without bundles wrapper', () => { + const bundle = validBundle(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'capability-bundle-')); + const bundlePath = path.join(tempDir, 'bundle.json'); + fs.writeFileSync(bundlePath, JSON.stringify(bundle), 'utf8'); + + const loaded = loadCapabilityBundles(bundlePath, { + knownCapabilities: ['keepalive/static-spa'], + now: new Date('2026-01-01T00:00:00Z'), + }); + + assert.equal(loaded.length, 1); + assert.equal(loaded[0].capability_id, 'keepalive/static-spa'); +}); + +test('prompt composer applies matching capability and reports exact id and hash', () => { + const bundle = validBundle(); + const result = composePrompt({ + knownCapabilities: ['keepalive/static-spa'], + capabilityBundles: [bundle], + context: { + repo: 'stranske/Inv-Man-Intake', + agent: 'codex', + labels: ['agents:keepalive'], + }, + mode: 'normal', + segments: [{ id: 'base', text: 'Base instructions' }], + }); + + assert.match(result.text, /Capability: keepalive\/static-spa@1\.0\.0/); + assert.match(result.text, /Base instructions/); + assert.deepEqual(result.segments, ['capability-bundle', 'base']); + assert.equal(result.capability_bundles.applied[0].content_hash, bundle.content_hash); +}); + +test('nonmatching bundle reports rejection reason and applies no fragment', () => { + const bundle = validBundle(); + const selected = selectCapabilityBundles( + [bundle], + { repo: 'stranske/Workflows', agent: 'codex', mode: 'normal', labels: ['agents:keepalive'] }, + { knownCapabilities: ['keepalive/static-spa'] }, + ); + + assert.deepEqual(selected.applied, []); + assert.equal(selected.rejected[0].reason, 'repo'); + assert.equal(renderCapabilityFragments(selected.applied), ''); +}); + +test('keepalive metrics carry applied bundle metadata and rejection reasons', () => { + const bundle = validBundle(); + const capabilityBundles = { + applied: [ + { + capability_id: bundle.capability_id, + content_hash: bundle.content_hash, + gate_versions: bundle.gates, + playbooks: bundle.playbooks, + }, + ], + rejected: [{ capability_id: 'keepalive/other', reason: 'repo' }], + }; + + const record = buildMetricsRecord({ + prNumber: 123, + iteration: 2, + action: 'run', + errorCategory: 'none', + durationMs: 10, + tasksTotal: 3, + tasksComplete: 1, + capabilityBundles, + }); + + assert.deepEqual(record.capability_bundle_ids, ['keepalive/static-spa']); + assert.deepEqual(record.capability_bundle_hashes, [bundle.content_hash]); + assert.deepEqual(record.capability_gate_versions, [ + 'frontend_verify@1', + 'offline_bundle@1', + 'docs/keepalive/KEEPALIVE_TROUBLESHOOTING.md', + ]); + assert.deepEqual(record.capability_rejection_reasons, ['repo']); +}); + +test('capability bundle metrics input parser preserves applied and rejected arrays', () => { + const parsed = parseCapabilityBundlesInput(JSON.stringify({ + applied: [{ capability_id: 'keepalive/static-spa' }], + rejected: [{ reason: 'repo' }], + })); + + assert.deepEqual(parsed.applied, [{ capability_id: 'keepalive/static-spa' }]); + assert.deepEqual(parsed.rejected, [{ reason: 'repo' }]); + assert.deepEqual(parseCapabilityBundlesInput('not-json').rejected, [ + { reason: 'invalid-capability-bundles-json' }, + ]); +}); diff --git a/.github/scripts/__tests__/keepalive-prompt-composer.test.js b/.github/scripts/__tests__/keepalive-prompt-composer.test.js index 18111b985..fe0e354bd 100644 --- a/.github/scripts/__tests__/keepalive-prompt-composer.test.js +++ b/.github/scripts/__tests__/keepalive-prompt-composer.test.js @@ -4,6 +4,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { createPromptComposer, composePrompt } = require('../keepalive_prompt_composer'); +const { computeCapabilityBundleHash } = require('../capability_bundle'); test('createPromptComposer composes segments in order with default separator', () => { const composer = createPromptComposer({ @@ -54,3 +55,30 @@ test('composePrompt ignores empty segment content', () => { assert.equal(result.text, 'Visible'); assert.deepEqual(result.segments, ['ok']); }); + +test('composePrompt respects an explicit empty capability bundle override', () => { + const bundle = { + schema_version: 'capability-bundle/v1', + capability_id: 'keepalive/default', + version: '1.0.0', + selector: { repo: 'stranske/Workflows', agent: 'codex' }, + owner: 'stranske/Workflows', + fragments: { task: 'Default task fragment' }, + gates: ['default-gate@1'], + rollback: 'Remove default bundle.', + }; + bundle.content_hash = computeCapabilityBundleHash(bundle); + + const composer = createPromptComposer({ + capabilityBundles: [bundle], + segments: [{ id: 'base', text: 'Base instructions' }], + }); + const result = composer.compose({ + capabilityBundles: [], + context: { repo: 'stranske/Workflows', agent: 'codex' }, + }); + + assert.equal(result.text, 'Base instructions'); + assert.deepEqual(result.segments, ['base']); + assert.deepEqual(result.capability_bundles.applied, []); +}); diff --git a/.github/scripts/capability_bundle.js b/.github/scripts/capability_bundle.js new file mode 100644 index 000000000..81f62b906 --- /dev/null +++ b/.github/scripts/capability_bundle.js @@ -0,0 +1,270 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); + +const SCHEMA_VERSION = 'capability-bundle/v1'; +const ALLOWED_TOP_LEVEL_KEYS = new Set([ + 'schema_version', + 'capability_id', + 'version', + 'content_hash', + 'selector', + 'owner', + 'fragments', + 'gates', + 'playbooks', + 'expires_at', + 'rollback', +]); +const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._/-]*$/; +const VERSION_PATTERN = /^v?[0-9]+(\.[0-9]+){0,2}$/; +const FORBIDDEN_KEY_PATTERN = /(?:raw[_-]?prompt|credential|secret|api[_-]?key|local[_-]?weight|posterior[_-]?weight|command|control|exec)/i; + +function normalise(value) { + return String(value ?? '').trim(); +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function sha256Hex(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function bundleHashPayload(bundle) { + const { + content_hash: _contentHash, + contentHash: _contentHashCamel, + ...payload + } = bundle || {}; + return payload; +} + +function computeCapabilityBundleHash(bundle) { + return `sha256:${sha256Hex(stableStringify(bundleHashPayload(bundle)))}`; +} + +function walkForbiddenKeys(value, path = []) { + const hits = []; + if (Array.isArray(value)) { + value.forEach((item, index) => { + hits.push(...walkForbiddenKeys(item, [...path, String(index)])); + }); + return hits; + } + if (!value || typeof value !== 'object') { + return hits; + } + for (const [key, child] of Object.entries(value)) { + const childPath = [...path, key]; + if (FORBIDDEN_KEY_PATTERN.test(key)) { + hits.push(childPath.join('.')); + } + hits.push(...walkForbiddenKeys(child, childPath)); + } + return hits; +} + +function asArray(value) { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function requireNonEmpty(value, fieldName) { + if (!normalise(value)) { + throw new Error(`capability bundle missing ${fieldName}`); + } +} + +function validateCapabilityBundle(bundle, options = {}) { + const knownCapabilities = new Set(asArray(options.knownCapabilities)); + const now = options.now instanceof Date ? options.now : new Date(); + + if (!bundle || typeof bundle !== 'object' || Array.isArray(bundle)) { + throw new Error('capability bundle must be an object'); + } + if (normalise(bundle.schema_version) !== SCHEMA_VERSION) { + throw new Error(`capability bundle schema_version must be ${SCHEMA_VERSION}`); + } + const unknownKeys = Object.keys(bundle).filter((key) => !ALLOWED_TOP_LEVEL_KEYS.has(key)); + if (unknownKeys.length > 0) { + throw new Error(`capability bundle has unknown top-level fields: ${unknownKeys.join(', ')}`); + } + const capabilityId = normalise(bundle.capability_id); + if (!capabilityId) { + throw new Error('capability bundle missing capability_id'); + } + if (!CAPABILITY_ID_PATTERN.test(capabilityId)) { + throw new Error(`capability bundle has invalid capability_id: ${capabilityId}`); + } + if (knownCapabilities.size > 0 && !knownCapabilities.has(capabilityId)) { + throw new Error(`unknown capability id: ${capabilityId}`); + } + const version = normalise(bundle.version); + if (!version) { + throw new Error('capability bundle missing version'); + } + if (!VERSION_PATTERN.test(version)) { + throw new Error(`capability bundle has invalid version: ${version}`); + } + requireNonEmpty(bundle.owner, 'owner'); + requireNonEmpty(bundle.rollback, 'rollback'); + if (!bundle.selector || typeof bundle.selector !== 'object' || Array.isArray(bundle.selector)) { + throw new Error('capability bundle missing selector object'); + } + if (!bundle.fragments || typeof bundle.fragments !== 'object' || Array.isArray(bundle.fragments)) { + throw new Error('capability bundle missing fragments object'); + } + if (!normalise(bundle.fragments.task) && !normalise(bundle.fragments.acceptance)) { + throw new Error('capability bundle must include a task or acceptance fragment'); + } + if (!Array.isArray(bundle.gates) || bundle.gates.length === 0 || bundle.gates.some((gate) => !normalise(gate))) { + throw new Error('capability bundle must include at least one gate ref'); + } + const forbidden = walkForbiddenKeys(bundle); + if (forbidden.length > 0) { + throw new Error(`capability bundle contains unsafe inline fields: ${forbidden.join(', ')}`); + } + const expiresAt = normalise(bundle.expires_at); + if (expiresAt) { + const expiry = new Date(expiresAt); + if (Number.isNaN(expiry.getTime())) { + throw new Error(`capability bundle has invalid expires_at: ${expiresAt}`); + } + if (expiry.getTime() <= now.getTime()) { + throw new Error(`capability bundle expired at ${expiresAt}`); + } + } + const expectedHash = normalise(bundle.content_hash); + if (!expectedHash) { + throw new Error('capability bundle missing content_hash'); + } + const actualHash = computeCapabilityBundleHash(bundle); + if (expectedHash !== actualHash) { + throw new Error(`capability bundle hash mismatch: expected ${expectedHash}, computed ${actualHash}`); + } + return true; +} + +function loadCapabilityBundles(bundlePath, options = {}) { + const raw = fs.readFileSync(bundlePath, 'utf8'); + const parsed = JSON.parse(raw); + const bundles = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.bundles) + ? parsed.bundles + : [parsed]; + bundles.forEach((bundle) => validateCapabilityBundle(bundle, options)); + return bundles; +} + +function predicateMatches(expected, actual) { + if (expected === undefined || expected === null) { + return true; + } + if (Array.isArray(expected)) { + return expected.map(normalise).filter(Boolean).includes(normalise(actual)); + } + return normalise(expected) === normalise(actual); +} + +function selectorMatches(selector = {}, context = {}) { + const labels = new Set(asArray(context.labels).map((label) => normalise(label).toLowerCase())); + if (!predicateMatches(selector.repo, context.repo)) { + return [false, 'repo']; + } + if (!predicateMatches(selector.agent, context.agent)) { + return [false, 'agent']; + } + if (!predicateMatches(selector.mode, context.mode)) { + return [false, 'mode']; + } + for (const requiredLabel of asArray(selector.labels)) { + if (!labels.has(normalise(requiredLabel).toLowerCase())) { + return [false, `label:${requiredLabel}`]; + } + } + return [true, 'matched']; +} + +function selectCapabilityBundles(bundles, context = {}, options = {}) { + const applied = []; + const rejected = []; + for (const bundle of asArray(bundles)) { + try { + validateCapabilityBundle(bundle, options); + const [matched, reason] = selectorMatches(bundle.selector, context); + if (!matched) { + rejected.push({ + capability_id: normalise(bundle.capability_id), + content_hash: normalise(bundle.content_hash), + reason, + }); + continue; + } + applied.push({ + capability_id: normalise(bundle.capability_id), + version: normalise(bundle.version), + content_hash: normalise(bundle.content_hash), + gate_versions: asArray(bundle.gates).map((gate) => normalise(gate)).filter(Boolean), + playbooks: asArray(bundle.playbooks).map((playbook) => normalise(playbook)).filter(Boolean), + fragments: { + task: normalise(bundle.fragments?.task), + acceptance: normalise(bundle.fragments?.acceptance), + }, + }); + } catch (error) { + rejected.push({ + capability_id: normalise(bundle?.capability_id) || 'unknown', + content_hash: normalise(bundle?.content_hash), + reason: error.message, + }); + } + } + return { applied, rejected }; +} + +function renderCapabilityFragments(applied = []) { + const blocks = asArray(applied) + .map((bundle) => { + const lines = [ + `Capability: ${bundle.capability_id}@${bundle.version}`, + `Hash: ${bundle.content_hash}`, + ]; + if (bundle.fragments?.task) { + lines.push(`Task fragment: ${bundle.fragments.task}`); + } + if (bundle.fragments?.acceptance) { + lines.push(`Acceptance fragment: ${bundle.fragments.acceptance}`); + } + if (bundle.gate_versions?.length) { + lines.push(`Gates: ${bundle.gate_versions.join(', ')}`); + } + return lines.join('\n'); + }) + .filter(Boolean); + return blocks.join('\n\n'); +} + +module.exports = { + SCHEMA_VERSION, + computeCapabilityBundleHash, + loadCapabilityBundles, + renderCapabilityFragments, + selectCapabilityBundles, + stableStringify, + validateCapabilityBundle, +}; diff --git a/.github/scripts/keepalive_loop.js b/.github/scripts/keepalive_loop.js index 522a16e0f..1feceb96c 100644 --- a/.github/scripts/keepalive_loop.js +++ b/.github/scripts/keepalive_loop.js @@ -796,7 +796,13 @@ function buildMetricsRecord({ durationMs, tasksTotal, tasksComplete, + capabilityBundles, }) { + const capabilityResult = capabilityBundles && typeof capabilityBundles === 'object' + ? capabilityBundles + : { applied: [], rejected: [] }; + const applied = Array.isArray(capabilityResult.applied) ? capabilityResult.applied : []; + const rejected = Array.isArray(capabilityResult.rejected) ? capabilityResult.rejected : []; return { pr_number: toNumber(prNumber, 0), iteration: Math.max(1, toNumber(iteration, 0)), @@ -806,9 +812,40 @@ function buildMetricsRecord({ duration_ms: Math.max(0, toNumber(durationMs, 0)), tasks_total: Math.max(0, toNumber(tasksTotal, 0)), tasks_complete: Math.max(0, toNumber(tasksComplete, 0)), + capability_bundle_ids: applied.map((bundle) => normalise(bundle.capability_id)).filter(Boolean), + capability_bundle_hashes: applied.map((bundle) => normalise(bundle.content_hash)).filter(Boolean), + capability_gate_versions: applied + .flatMap((bundle) => [ + ...(Array.isArray(bundle.gate_versions) ? bundle.gate_versions : []), + ...(Array.isArray(bundle.playbooks) ? bundle.playbooks : []), + ]) + .map(normalise) + .filter(Boolean), + capability_rejection_reasons: rejected + .map((bundle) => normalise(bundle.reason)) + .filter(Boolean), }; } +function parseCapabilityBundlesInput(value) { + const raw = normalise(value); + if (!raw) { + return { applied: [], rejected: [] }; + } + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { applied: [], rejected: [{ reason: 'invalid-capability-bundles-json' }] }; + } + return { + applied: Array.isArray(parsed.applied) ? parsed.applied : [], + rejected: Array.isArray(parsed.rejected) ? parsed.rejected : [], + }; + } catch { + return { applied: [], rejected: [{ reason: 'invalid-capability-bundles-json' }] }; + } +} + function emitMetricsRecord({ core, record }) { if (core && typeof core.setOutput === 'function') { core.setOutput('metrics_record_json', JSON.stringify(record)); @@ -3357,6 +3394,9 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in durationMs: toOptionalNumber(inputs.duration_ms ?? inputs.durationMs), startTs: toOptionalNumber(inputs.start_ts ?? inputs.startTs), }); + const capabilityBundles = parseCapabilityBundlesInput( + inputs.capability_bundles_json ?? inputs.capabilityBundlesJson, + ); const metricsRecord = buildMetricsRecord({ prNumber, iteration: metricsIteration, @@ -3365,6 +3405,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in durationMs, tasksTotal, tasksComplete, + capabilityBundles, }); emitMetricsRecord({ core, record: metricsRecord }); await appendMetricsRecord({ @@ -4786,4 +4827,6 @@ module.exports = { extractScopePatterns, fileMatchesScopePattern, validateScopeCompliance, + buildMetricsRecord, + parseCapabilityBundlesInput, }; diff --git a/.github/scripts/keepalive_prompt_composer.js b/.github/scripts/keepalive_prompt_composer.js index 60eacf8b4..f2d1d115d 100644 --- a/.github/scripts/keepalive_prompt_composer.js +++ b/.github/scripts/keepalive_prompt_composer.js @@ -1,6 +1,10 @@ 'use strict'; const DEFAULT_SEPARATOR = '\n\n'; +const { + renderCapabilityFragments, + selectCapabilityBundles, +} = require('./capability_bundle'); function normalise(value) { return String(value ?? '').trim(); @@ -15,7 +19,7 @@ function normaliseSegmentId(value, fallback) { } function coerceSegments(value) { - if (!value) { + if (value === undefined || value === null) { return []; } if (Array.isArray(value)) { @@ -27,6 +31,8 @@ function coerceSegments(value) { function createPromptComposer(options = {}) { const segments = coerceSegments(options.segments); const separator = normalise(options.separator) || DEFAULT_SEPARATOR; + const capabilityBundles = coerceSegments(options.capabilityBundles); + const knownCapabilities = coerceSegments(options.knownCapabilities).map(normalise).filter(Boolean); const compose = (params = {}) => { const state = params.state && typeof params.state === 'object' ? params.state : {}; @@ -34,6 +40,18 @@ function createPromptComposer(options = {}) { const mode = normalise(params.mode); const rendered = []; const usedSegments = []; + const capabilityResult = selectCapabilityBundles( + Object.prototype.hasOwnProperty.call(params, 'capabilityBundles') + ? coerceSegments(params.capabilityBundles) + : capabilityBundles, + { ...context, mode }, + { knownCapabilities }, + ); + const capabilityText = renderCapabilityFragments(capabilityResult.applied); + if (capabilityText) { + rendered.push(capabilityText); + usedSegments.push('capability-bundle'); + } segments.forEach((segment, index) => { if (!segment || typeof segment !== 'object') { @@ -67,6 +85,7 @@ function createPromptComposer(options = {}) { text: rendered.join(separator).trim(), segments: usedSegments, separator, + capability_bundles: capabilityResult, }; }; diff --git a/.github/sync-manifest.yml b/.github/sync-manifest.yml index 2ce07a556..a279f923a 100644 --- a/.github/sync-manifest.yml +++ b/.github/sync-manifest.yml @@ -421,6 +421,9 @@ scripts: - source: .github/scripts/keepalive_prompt_composer.js description: "Composes prompts for keepalive Codex calls" + - source: .github/scripts/capability_bundle.js + description: "Validates and selects capability-bundle/v1 fragments for keepalive prompts and metrics" + - source: .github/scripts/autopilot_metrics.js description: "AutoPilot metrics collection and reporting" @@ -793,6 +796,14 @@ docs: target: docs/contracts/identity-map-conventions.md description: "Canonical entity-ID conventions for run-contract/v1 identity_refs / evidence entity_ref - participants emit against these" + - source: docs/contracts/capability-bundle-v1.md + target: docs/contracts/capability-bundle-v1.md + description: "Capability-bundle/v1 prompt-fragment and gate metadata contract for Workflows Keepalive" + + - source: docs/contracts/schemas/capability-bundle-v1.schema.json + target: docs/contracts/schemas/capability-bundle-v1.schema.json + description: "JSON Schema (draft 2020-12) for capability-bundle/v1 keepalive prompt fragments and gate metadata" + - source: docs/contracts/schemas/run-contract-v1.schema.json target: docs/contracts/schemas/run-contract-v1.schema.json description: "JSON Schema (draft 2020-12) for run-contract/v1 run envelopes - loaded by the validator and local helpers so participants validate without a Workflows checkout" diff --git a/docs/contracts/capability-bundle-v1.md b/docs/contracts/capability-bundle-v1.md new file mode 100644 index 000000000..87febf3b1 --- /dev/null +++ b/docs/contracts/capability-bundle-v1.md @@ -0,0 +1,12 @@ +# Capability Bundle v1 + +`capability-bundle/v1` is the neutral Workflows contract for portable, deterministic keepalive capability fragments. A bundle may carry task and acceptance fragments plus gate/playbook references, but it must not carry local posterior weights, credentials, raw prompts, or autonomous local-control commands. + +Required fields: + +- `capability_id`, `version`, and `content_hash` +- deterministic `selector` predicates such as repo, agent, mode, and labels +- `owner`, `fragments`, `gates`, and `rollback` +- optional `expires_at` and `playbooks` + +The content hash is `sha256:` over the canonical JSON payload excluding `content_hash`. Keepalive prompt composition reports applied bundle IDs/hashes and rejected reasons; keepalive metrics record the same evidence so downstream dashboards can distinguish "no bundle matched" from "bundle applied." diff --git a/docs/contracts/schemas/capability-bundle-v1.schema.json b/docs/contracts/schemas/capability-bundle-v1.schema.json new file mode 100644 index 000000000..7e5681b01 --- /dev/null +++ b/docs/contracts/schemas/capability-bundle-v1.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/stranske/Workflows/docs/contracts/schemas/capability-bundle-v1.schema.json", + "title": "capability-bundle/v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "capability_id", + "version", + "content_hash", + "selector", + "owner", + "fragments", + "gates", + "rollback" + ], + "properties": { + "schema_version": { "const": "capability-bundle/v1" }, + "capability_id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._/-]*$" + }, + "version": { + "type": "string", + "pattern": "^v?[0-9]+(\\.[0-9]+){0,2}$" + }, + "content_hash": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "selector": { + "type": "object", + "additionalProperties": false, + "properties": { + "repo": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + ] + }, + "agent": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + ] + }, + "mode": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + ] + }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + } + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "fragments": { + "type": "object", + "additionalProperties": false, + "properties": { + "task": { "type": "string" }, + "acceptance": { "type": "string" } + }, + "anyOf": [ + { "required": ["task"] }, + { "required": ["acceptance"] } + ] + }, + "gates": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "playbooks": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "rollback": { + "type": "string", + "minLength": 1 + } + }, + "not": { + "anyOf": [ + { "required": ["raw_prompt"] }, + { "required": ["credentials"] }, + { "required": ["local_weights"] }, + { "required": ["posterior_weights"] } + ] + } +} diff --git a/docs/keepalive/METRICS_SCHEMA.md b/docs/keepalive/METRICS_SCHEMA.md index df8fe91c7..acddeab85 100644 --- a/docs/keepalive/METRICS_SCHEMA.md +++ b/docs/keepalive/METRICS_SCHEMA.md @@ -21,6 +21,10 @@ The metrics log supports two record types: - duration_ms: Integer duration in milliseconds for the iteration. - tasks_total: Integer total tasks detected for the PR. - tasks_complete: Integer completed tasks detected for the PR. +- capability_bundle_ids: Array of applied `capability-bundle/v1` IDs. +- capability_bundle_hashes: Array of applied bundle content hashes. +- capability_gate_versions: Array of gate refs and playbook refs attached by applied bundles. +- capability_rejection_reasons: Array of deterministic reasons bundles were not applied. - metric_type: Optional string. When present, set to `"keepalive"`. ## Post-Merge Summary Fields @@ -38,7 +42,7 @@ The metrics log supports two record types: ## Example Record ```json -{"pr_number":1234,"iteration":2,"timestamp":"2025-01-15T12:34:56Z","action":"retry","error_category":"none","duration_ms":4821,"tasks_total":14,"tasks_complete":6} +{"pr_number":1234,"iteration":2,"timestamp":"2025-01-15T12:34:56Z","action":"retry","error_category":"none","duration_ms":4821,"tasks_total":14,"tasks_complete":6,"capability_bundle_ids":["keepalive/static-spa"],"capability_bundle_hashes":["sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"capability_gate_versions":["frontend_verify@1","docs/keepalive/KEEPALIVE_TROUBLESHOOTING.md"],"capability_rejection_reasons":[]} ``` ## Example Post-Merge Record diff --git a/langsmith-fleet-worker-attempt.json b/langsmith-fleet-worker-attempt.json new file mode 100644 index 000000000..bb041799f --- /dev/null +++ b/langsmith-fleet-worker-attempt.json @@ -0,0 +1,16 @@ +{ + "agent": "codex", + "cli_version": "0.125.0", + "emitted_at": "2026-07-10T09:06:49.995411Z", + "execution_profile": "codex-default", + "fallback_models": [ + "gpt-5.4" + ], + "operation_role": "worker", + "pr_number": "2744", + "requested_model": "gpt-5.5", + "runner": "reusable-codex-run", + "schema": "langsmith-fleet/v1", + "selected_model": "", + "selection_reason": "" +} diff --git a/templates/consumer-repo/.github/scripts/capability_bundle.js b/templates/consumer-repo/.github/scripts/capability_bundle.js new file mode 100644 index 000000000..81f62b906 --- /dev/null +++ b/templates/consumer-repo/.github/scripts/capability_bundle.js @@ -0,0 +1,270 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); + +const SCHEMA_VERSION = 'capability-bundle/v1'; +const ALLOWED_TOP_LEVEL_KEYS = new Set([ + 'schema_version', + 'capability_id', + 'version', + 'content_hash', + 'selector', + 'owner', + 'fragments', + 'gates', + 'playbooks', + 'expires_at', + 'rollback', +]); +const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._/-]*$/; +const VERSION_PATTERN = /^v?[0-9]+(\.[0-9]+){0,2}$/; +const FORBIDDEN_KEY_PATTERN = /(?:raw[_-]?prompt|credential|secret|api[_-]?key|local[_-]?weight|posterior[_-]?weight|command|control|exec)/i; + +function normalise(value) { + return String(value ?? '').trim(); +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function sha256Hex(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function bundleHashPayload(bundle) { + const { + content_hash: _contentHash, + contentHash: _contentHashCamel, + ...payload + } = bundle || {}; + return payload; +} + +function computeCapabilityBundleHash(bundle) { + return `sha256:${sha256Hex(stableStringify(bundleHashPayload(bundle)))}`; +} + +function walkForbiddenKeys(value, path = []) { + const hits = []; + if (Array.isArray(value)) { + value.forEach((item, index) => { + hits.push(...walkForbiddenKeys(item, [...path, String(index)])); + }); + return hits; + } + if (!value || typeof value !== 'object') { + return hits; + } + for (const [key, child] of Object.entries(value)) { + const childPath = [...path, key]; + if (FORBIDDEN_KEY_PATTERN.test(key)) { + hits.push(childPath.join('.')); + } + hits.push(...walkForbiddenKeys(child, childPath)); + } + return hits; +} + +function asArray(value) { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function requireNonEmpty(value, fieldName) { + if (!normalise(value)) { + throw new Error(`capability bundle missing ${fieldName}`); + } +} + +function validateCapabilityBundle(bundle, options = {}) { + const knownCapabilities = new Set(asArray(options.knownCapabilities)); + const now = options.now instanceof Date ? options.now : new Date(); + + if (!bundle || typeof bundle !== 'object' || Array.isArray(bundle)) { + throw new Error('capability bundle must be an object'); + } + if (normalise(bundle.schema_version) !== SCHEMA_VERSION) { + throw new Error(`capability bundle schema_version must be ${SCHEMA_VERSION}`); + } + const unknownKeys = Object.keys(bundle).filter((key) => !ALLOWED_TOP_LEVEL_KEYS.has(key)); + if (unknownKeys.length > 0) { + throw new Error(`capability bundle has unknown top-level fields: ${unknownKeys.join(', ')}`); + } + const capabilityId = normalise(bundle.capability_id); + if (!capabilityId) { + throw new Error('capability bundle missing capability_id'); + } + if (!CAPABILITY_ID_PATTERN.test(capabilityId)) { + throw new Error(`capability bundle has invalid capability_id: ${capabilityId}`); + } + if (knownCapabilities.size > 0 && !knownCapabilities.has(capabilityId)) { + throw new Error(`unknown capability id: ${capabilityId}`); + } + const version = normalise(bundle.version); + if (!version) { + throw new Error('capability bundle missing version'); + } + if (!VERSION_PATTERN.test(version)) { + throw new Error(`capability bundle has invalid version: ${version}`); + } + requireNonEmpty(bundle.owner, 'owner'); + requireNonEmpty(bundle.rollback, 'rollback'); + if (!bundle.selector || typeof bundle.selector !== 'object' || Array.isArray(bundle.selector)) { + throw new Error('capability bundle missing selector object'); + } + if (!bundle.fragments || typeof bundle.fragments !== 'object' || Array.isArray(bundle.fragments)) { + throw new Error('capability bundle missing fragments object'); + } + if (!normalise(bundle.fragments.task) && !normalise(bundle.fragments.acceptance)) { + throw new Error('capability bundle must include a task or acceptance fragment'); + } + if (!Array.isArray(bundle.gates) || bundle.gates.length === 0 || bundle.gates.some((gate) => !normalise(gate))) { + throw new Error('capability bundle must include at least one gate ref'); + } + const forbidden = walkForbiddenKeys(bundle); + if (forbidden.length > 0) { + throw new Error(`capability bundle contains unsafe inline fields: ${forbidden.join(', ')}`); + } + const expiresAt = normalise(bundle.expires_at); + if (expiresAt) { + const expiry = new Date(expiresAt); + if (Number.isNaN(expiry.getTime())) { + throw new Error(`capability bundle has invalid expires_at: ${expiresAt}`); + } + if (expiry.getTime() <= now.getTime()) { + throw new Error(`capability bundle expired at ${expiresAt}`); + } + } + const expectedHash = normalise(bundle.content_hash); + if (!expectedHash) { + throw new Error('capability bundle missing content_hash'); + } + const actualHash = computeCapabilityBundleHash(bundle); + if (expectedHash !== actualHash) { + throw new Error(`capability bundle hash mismatch: expected ${expectedHash}, computed ${actualHash}`); + } + return true; +} + +function loadCapabilityBundles(bundlePath, options = {}) { + const raw = fs.readFileSync(bundlePath, 'utf8'); + const parsed = JSON.parse(raw); + const bundles = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.bundles) + ? parsed.bundles + : [parsed]; + bundles.forEach((bundle) => validateCapabilityBundle(bundle, options)); + return bundles; +} + +function predicateMatches(expected, actual) { + if (expected === undefined || expected === null) { + return true; + } + if (Array.isArray(expected)) { + return expected.map(normalise).filter(Boolean).includes(normalise(actual)); + } + return normalise(expected) === normalise(actual); +} + +function selectorMatches(selector = {}, context = {}) { + const labels = new Set(asArray(context.labels).map((label) => normalise(label).toLowerCase())); + if (!predicateMatches(selector.repo, context.repo)) { + return [false, 'repo']; + } + if (!predicateMatches(selector.agent, context.agent)) { + return [false, 'agent']; + } + if (!predicateMatches(selector.mode, context.mode)) { + return [false, 'mode']; + } + for (const requiredLabel of asArray(selector.labels)) { + if (!labels.has(normalise(requiredLabel).toLowerCase())) { + return [false, `label:${requiredLabel}`]; + } + } + return [true, 'matched']; +} + +function selectCapabilityBundles(bundles, context = {}, options = {}) { + const applied = []; + const rejected = []; + for (const bundle of asArray(bundles)) { + try { + validateCapabilityBundle(bundle, options); + const [matched, reason] = selectorMatches(bundle.selector, context); + if (!matched) { + rejected.push({ + capability_id: normalise(bundle.capability_id), + content_hash: normalise(bundle.content_hash), + reason, + }); + continue; + } + applied.push({ + capability_id: normalise(bundle.capability_id), + version: normalise(bundle.version), + content_hash: normalise(bundle.content_hash), + gate_versions: asArray(bundle.gates).map((gate) => normalise(gate)).filter(Boolean), + playbooks: asArray(bundle.playbooks).map((playbook) => normalise(playbook)).filter(Boolean), + fragments: { + task: normalise(bundle.fragments?.task), + acceptance: normalise(bundle.fragments?.acceptance), + }, + }); + } catch (error) { + rejected.push({ + capability_id: normalise(bundle?.capability_id) || 'unknown', + content_hash: normalise(bundle?.content_hash), + reason: error.message, + }); + } + } + return { applied, rejected }; +} + +function renderCapabilityFragments(applied = []) { + const blocks = asArray(applied) + .map((bundle) => { + const lines = [ + `Capability: ${bundle.capability_id}@${bundle.version}`, + `Hash: ${bundle.content_hash}`, + ]; + if (bundle.fragments?.task) { + lines.push(`Task fragment: ${bundle.fragments.task}`); + } + if (bundle.fragments?.acceptance) { + lines.push(`Acceptance fragment: ${bundle.fragments.acceptance}`); + } + if (bundle.gate_versions?.length) { + lines.push(`Gates: ${bundle.gate_versions.join(', ')}`); + } + return lines.join('\n'); + }) + .filter(Boolean); + return blocks.join('\n\n'); +} + +module.exports = { + SCHEMA_VERSION, + computeCapabilityBundleHash, + loadCapabilityBundles, + renderCapabilityFragments, + selectCapabilityBundles, + stableStringify, + validateCapabilityBundle, +}; diff --git a/templates/consumer-repo/.github/scripts/keepalive_loop.js b/templates/consumer-repo/.github/scripts/keepalive_loop.js index 522a16e0f..1feceb96c 100644 --- a/templates/consumer-repo/.github/scripts/keepalive_loop.js +++ b/templates/consumer-repo/.github/scripts/keepalive_loop.js @@ -796,7 +796,13 @@ function buildMetricsRecord({ durationMs, tasksTotal, tasksComplete, + capabilityBundles, }) { + const capabilityResult = capabilityBundles && typeof capabilityBundles === 'object' + ? capabilityBundles + : { applied: [], rejected: [] }; + const applied = Array.isArray(capabilityResult.applied) ? capabilityResult.applied : []; + const rejected = Array.isArray(capabilityResult.rejected) ? capabilityResult.rejected : []; return { pr_number: toNumber(prNumber, 0), iteration: Math.max(1, toNumber(iteration, 0)), @@ -806,9 +812,40 @@ function buildMetricsRecord({ duration_ms: Math.max(0, toNumber(durationMs, 0)), tasks_total: Math.max(0, toNumber(tasksTotal, 0)), tasks_complete: Math.max(0, toNumber(tasksComplete, 0)), + capability_bundle_ids: applied.map((bundle) => normalise(bundle.capability_id)).filter(Boolean), + capability_bundle_hashes: applied.map((bundle) => normalise(bundle.content_hash)).filter(Boolean), + capability_gate_versions: applied + .flatMap((bundle) => [ + ...(Array.isArray(bundle.gate_versions) ? bundle.gate_versions : []), + ...(Array.isArray(bundle.playbooks) ? bundle.playbooks : []), + ]) + .map(normalise) + .filter(Boolean), + capability_rejection_reasons: rejected + .map((bundle) => normalise(bundle.reason)) + .filter(Boolean), }; } +function parseCapabilityBundlesInput(value) { + const raw = normalise(value); + if (!raw) { + return { applied: [], rejected: [] }; + } + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { applied: [], rejected: [{ reason: 'invalid-capability-bundles-json' }] }; + } + return { + applied: Array.isArray(parsed.applied) ? parsed.applied : [], + rejected: Array.isArray(parsed.rejected) ? parsed.rejected : [], + }; + } catch { + return { applied: [], rejected: [{ reason: 'invalid-capability-bundles-json' }] }; + } +} + function emitMetricsRecord({ core, record }) { if (core && typeof core.setOutput === 'function') { core.setOutput('metrics_record_json', JSON.stringify(record)); @@ -3357,6 +3394,9 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in durationMs: toOptionalNumber(inputs.duration_ms ?? inputs.durationMs), startTs: toOptionalNumber(inputs.start_ts ?? inputs.startTs), }); + const capabilityBundles = parseCapabilityBundlesInput( + inputs.capability_bundles_json ?? inputs.capabilityBundlesJson, + ); const metricsRecord = buildMetricsRecord({ prNumber, iteration: metricsIteration, @@ -3365,6 +3405,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in durationMs, tasksTotal, tasksComplete, + capabilityBundles, }); emitMetricsRecord({ core, record: metricsRecord }); await appendMetricsRecord({ @@ -4786,4 +4827,6 @@ module.exports = { extractScopePatterns, fileMatchesScopePattern, validateScopeCompliance, + buildMetricsRecord, + parseCapabilityBundlesInput, }; diff --git a/templates/consumer-repo/.github/scripts/keepalive_prompt_composer.js b/templates/consumer-repo/.github/scripts/keepalive_prompt_composer.js index 60eacf8b4..f2d1d115d 100644 --- a/templates/consumer-repo/.github/scripts/keepalive_prompt_composer.js +++ b/templates/consumer-repo/.github/scripts/keepalive_prompt_composer.js @@ -1,6 +1,10 @@ 'use strict'; const DEFAULT_SEPARATOR = '\n\n'; +const { + renderCapabilityFragments, + selectCapabilityBundles, +} = require('./capability_bundle'); function normalise(value) { return String(value ?? '').trim(); @@ -15,7 +19,7 @@ function normaliseSegmentId(value, fallback) { } function coerceSegments(value) { - if (!value) { + if (value === undefined || value === null) { return []; } if (Array.isArray(value)) { @@ -27,6 +31,8 @@ function coerceSegments(value) { function createPromptComposer(options = {}) { const segments = coerceSegments(options.segments); const separator = normalise(options.separator) || DEFAULT_SEPARATOR; + const capabilityBundles = coerceSegments(options.capabilityBundles); + const knownCapabilities = coerceSegments(options.knownCapabilities).map(normalise).filter(Boolean); const compose = (params = {}) => { const state = params.state && typeof params.state === 'object' ? params.state : {}; @@ -34,6 +40,18 @@ function createPromptComposer(options = {}) { const mode = normalise(params.mode); const rendered = []; const usedSegments = []; + const capabilityResult = selectCapabilityBundles( + Object.prototype.hasOwnProperty.call(params, 'capabilityBundles') + ? coerceSegments(params.capabilityBundles) + : capabilityBundles, + { ...context, mode }, + { knownCapabilities }, + ); + const capabilityText = renderCapabilityFragments(capabilityResult.applied); + if (capabilityText) { + rendered.push(capabilityText); + usedSegments.push('capability-bundle'); + } segments.forEach((segment, index) => { if (!segment || typeof segment !== 'object') { @@ -67,6 +85,7 @@ function createPromptComposer(options = {}) { text: rendered.join(separator).trim(), segments: usedSegments, separator, + capability_bundles: capabilityResult, }; }; diff --git a/templates/consumer-repo/docs/contracts/capability-bundle-v1.md b/templates/consumer-repo/docs/contracts/capability-bundle-v1.md new file mode 100644 index 000000000..87febf3b1 --- /dev/null +++ b/templates/consumer-repo/docs/contracts/capability-bundle-v1.md @@ -0,0 +1,12 @@ +# Capability Bundle v1 + +`capability-bundle/v1` is the neutral Workflows contract for portable, deterministic keepalive capability fragments. A bundle may carry task and acceptance fragments plus gate/playbook references, but it must not carry local posterior weights, credentials, raw prompts, or autonomous local-control commands. + +Required fields: + +- `capability_id`, `version`, and `content_hash` +- deterministic `selector` predicates such as repo, agent, mode, and labels +- `owner`, `fragments`, `gates`, and `rollback` +- optional `expires_at` and `playbooks` + +The content hash is `sha256:` over the canonical JSON payload excluding `content_hash`. Keepalive prompt composition reports applied bundle IDs/hashes and rejected reasons; keepalive metrics record the same evidence so downstream dashboards can distinguish "no bundle matched" from "bundle applied." diff --git a/templates/consumer-repo/docs/contracts/schemas/capability-bundle-v1.schema.json b/templates/consumer-repo/docs/contracts/schemas/capability-bundle-v1.schema.json new file mode 100644 index 000000000..7e5681b01 --- /dev/null +++ b/templates/consumer-repo/docs/contracts/schemas/capability-bundle-v1.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/stranske/Workflows/docs/contracts/schemas/capability-bundle-v1.schema.json", + "title": "capability-bundle/v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "capability_id", + "version", + "content_hash", + "selector", + "owner", + "fragments", + "gates", + "rollback" + ], + "properties": { + "schema_version": { "const": "capability-bundle/v1" }, + "capability_id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._/-]*$" + }, + "version": { + "type": "string", + "pattern": "^v?[0-9]+(\\.[0-9]+){0,2}$" + }, + "content_hash": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "selector": { + "type": "object", + "additionalProperties": false, + "properties": { + "repo": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + ] + }, + "agent": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + ] + }, + "mode": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" }, "uniqueItems": true } + ] + }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + } + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "fragments": { + "type": "object", + "additionalProperties": false, + "properties": { + "task": { "type": "string" }, + "acceptance": { "type": "string" } + }, + "anyOf": [ + { "required": ["task"] }, + { "required": ["acceptance"] } + ] + }, + "gates": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "playbooks": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "rollback": { + "type": "string", + "minLength": 1 + } + }, + "not": { + "anyOf": [ + { "required": ["raw_prompt"] }, + { "required": ["credentials"] }, + { "required": ["local_weights"] }, + { "required": ["posterior_weights"] } + ] + } +}