diff --git a/.github/workflows/commercial-readiness.yml b/.github/workflows/commercial-readiness.yml index 76af7468..d413a939 100644 --- a/.github/workflows/commercial-readiness.yml +++ b/.github/workflows/commercial-readiness.yml @@ -36,6 +36,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -51,15 +52,19 @@ jobs: node packages/commercial-readiness/src/cli.mjs snapshot \ --repository "$GITHUB_REPOSITORY" \ --policy product/commercial-readiness-policy.json \ - --commit "$GITHUB_SHA" \ + --commit "${{ github.event.pull_request.head.sha || github.sha }}" \ --generated-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --output "$EVIDENCE_DIR/github-snapshot.json" - - name: Audit product capabilities and buyer gaps + - name: Audit capability maturity and canonical buyer gaps + env: + GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail - node packages/commercial-readiness/src/cli.mjs audit \ + node packages/commercial-readiness/src/buyer-gap-cli.mjs \ + --repository "$GITHUB_REPOSITORY" \ --manifest product/capabilities.json \ + --buyer-gaps product/buyer-gaps.json \ --snapshot "$EVIDENCE_DIR/github-snapshot.json" \ --policy product/commercial-readiness-policy.json \ --root . \ diff --git a/packages/commercial-readiness/package.json b/packages/commercial-readiness/package.json index 3d942326..756d5efc 100644 --- a/packages/commercial-readiness/package.json +++ b/packages/commercial-readiness/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "build": "node --check src/cli.mjs && node --check src/github-client.mjs", - "lint": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs", + "build": "node --check src/cli.mjs && node --check src/github-client.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs", + "lint": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs", "test": "node --test src/*.test.mjs", - "typecheck": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs" + "typecheck": "node --check src/schema.mjs && node --check src/audit.mjs && node --check src/pr-gate.mjs && node --check src/render.mjs && node --check src/github-client.mjs && node --check src/cli.mjs && node --check src/buyer-gaps.mjs && node --check src/buyer-gap-cli.mjs" } } diff --git a/packages/commercial-readiness/src/audit.mjs b/packages/commercial-readiness/src/audit.mjs index 4ee989de..be5b6744 100644 --- a/packages/commercial-readiness/src/audit.mjs +++ b/packages/commercial-readiness/src/audit.mjs @@ -1,5 +1,6 @@ import { lstat, readFile, realpath } from 'node:fs/promises'; import { resolve, sep } from 'node:path'; +import { attachBuyerGapEvidence } from './buyer-gaps.mjs'; import { MATURITY_LEVELS, MATURITY_RANK } from './schema.mjs'; const REPORT_SCHEMA = 'life-os.commercial-readiness-report.v1'; @@ -117,9 +118,27 @@ function missingEvidenceForTarget(capability, evidenceResults) { ].sort(); } +/** + * Evaluates configured capability maturity against repository evidence. + * + * When `buyerGapEvidence` is undefined, this preserves the legacy v1 report: + * `summary.unresolved_gaps` is the count of capability-evidence gaps and no + * canonical buyer-gap collections or counts are added. When buyer-gap evidence + * is provided, capability maturity and `summary.unresolved_gaps` remain intact + * while `attachBuyerGapEvidence` adds separate unresolved/resolved/unknown + * canonical buyer-gap collections and their summary counts. + * + * @param {object} manifest validated capability manifest to evaluate + * @param {object} options evaluation inputs + * @param {string} options.rootDir repository root containing evidence paths + * @param {string} options.generatedAt ISO-compatible report timestamp + * @param {string} options.commitSha exact audited commit SHA + * @param {object|undefined} options.buyerGapEvidence optional canonical buyer-gap evaluation + * @returns {Promise} immutable-input-derived commercial readiness report + */ export async function evaluateCapabilities( manifest, - { rootDir, generatedAt, commitSha }, + { rootDir, generatedAt, commitSha, buyerGapEvidence }, ) { if (typeof rootDir !== 'string' || !rootDir) throw new Error('Repository root is required'); @@ -196,7 +215,7 @@ export async function evaluateCapabilities( weightedTarget += targetRank * capability.customer_impact; } - return { + const report = { schema: REPORT_SCHEMA, generated_at: new Date(generatedAt).toISOString(), commit_sha: commitSha.toLowerCase(), @@ -212,4 +231,8 @@ export async function evaluateCapabilities( capabilities, gaps, }; + + return buyerGapEvidence === undefined + ? report + : attachBuyerGapEvidence(report, buyerGapEvidence); } diff --git a/packages/commercial-readiness/src/buyer-gap-audit.test.mjs b/packages/commercial-readiness/src/buyer-gap-audit.test.mjs new file mode 100644 index 00000000..57032275 --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-audit.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { evaluateCapabilities } from './audit.mjs'; + +const manifest = { + capabilities: [ + { + id: 'planning.durable-data', + outcome: 'Durable planning works.', + target_maturity: 'production', + customer_impact: 5, + risk: 5, + acquisition_impact: 5, + effort: 1, + dependencies: [], + tracking_issue: 121, + evidence: [ + { + maturity: 'production', + mode: 'contains', + path: 'evidence.txt', + value: 'durable', + max_bytes: 1024, + }, + ], + }, + ], +}; + +async function evaluate(rootDir, buyerGapEvidence) { + return await evaluateCapabilities(manifest, { + rootDir, + generatedAt: '2026-08-09T11:00:00.000Z', + commitSha: 'a'.repeat(40), + buyerGapEvidence, + }); +} + +describe('evaluateCapabilities with canonical buyer-gap evidence', () => { + it('keeps the configured maturity result byte-for-byte equivalent while adding dimensions', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'life-os-buyer-gap-audit-')); + await writeFile(join(rootDir, 'evidence.txt'), 'durable', 'utf8'); + + const legacy = await evaluate(rootDir, undefined); + const enriched = await evaluate(rootDir, { + unresolved: [ + { + gap_id: 'today.multi-device-sync', + issue_number: 121, + capability_ids: ['planning.durable-data'], + state: 'open', + resolution: null, + }, + ], + resolved: [], + unknown: [], + }); + + assert.deepEqual(enriched.capabilities, legacy.capabilities); + assert.deepEqual(enriched.gaps, legacy.gaps); + assert.equal( + enriched.summary.weighted_maturity_percent, + legacy.summary.weighted_maturity_percent, + ); + assert.equal(enriched.summary.unresolved_gaps, legacy.summary.unresolved_gaps); + assert.equal(enriched.summary.capability_evidence_gaps, 0); + assert.equal(enriched.summary.unresolved_buyer_gaps, 1); + assert.equal(enriched.summary.unknown_buyer_gap_states, 0); + }); +}); diff --git a/packages/commercial-readiness/src/buyer-gap-cli.mjs b/packages/commercial-readiness/src/buyer-gap-cli.mjs new file mode 100644 index 00000000..6cd820bb --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-cli.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import { lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import { evaluateCapabilities } from './audit.mjs'; +import { + collectBuyerGapSnapshot, + evaluateBuyerGaps, + validateBuyerGapRegistry, +} from './buyer-gaps.mjs'; +import { GitHubApiClient } from './github-client.mjs'; +import { renderCommercialReadinessIssue } from './render.mjs'; +import { + validateCapabilityManifest, + validateCommercialReadinessPolicy, + validateGitHubSnapshot, +} from './schema.mjs'; + +const FLAG_TO_KEY = Object.freeze({ + '--repository': 'repository', + '--manifest': 'manifest', + '--buyer-gaps': 'buyerGaps', + '--snapshot': 'snapshot', + '--policy': 'policy', + '--root': 'root', + '--output-json': 'outputJson', + '--output-markdown': 'outputMarkdown', +}); +const REQUIRED_KEYS = Object.freeze(Object.values(FLAG_TO_KEY)); + +function invalidCommand() { + throw new Error('Invalid buyer gap audit command'); +} + +/** Parses the fixed, non-shell commercial buyer-gap audit command surface. */ +export function parseBuyerGapArguments(argv) { + if (!Array.isArray(argv)) invalidCommand(); + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const key = FLAG_TO_KEY[argv[index]]; + if (!key || Object.hasOwn(options, key)) invalidCommand(); + const value = argv[index + 1]; + if ( + typeof value !== 'string' || + !value || + value.startsWith('--') || + value.length > 500 || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + invalidCommand(); + } + options[key] = value; + index += 1; + } + if (REQUIRED_KEYS.some((key) => !Object.hasOwn(options, key))) invalidCommand(); + return options; +} + +async function readJson(path, maxBytes = 1024 * 1024) { + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error('Buyer gap audit input must be a regular file'); + } + if (metadata.size > maxBytes) { + throw new Error('Buyer gap audit input exceeded the size limit'); + } + try { + return JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + if (error instanceof SyntaxError) throw new Error('Buyer gap audit JSON was invalid'); + throw error; + } +} + +async function writeAtomic(path, content) { + const target = resolve(path); + await mkdir(dirname(target), { recursive: true }); + const temporary = `${target}.${randomUUID()}.tmp`; + await writeFile(temporary, content, { encoding: 'utf8', mode: 0o600 }); + await rename(temporary, target); +} + +/** Runs the capability audit and canonical buyer-gap reconciliation together. */ +export async function runBuyerGapAudit(options, environment = process.env) { + const [manifestValue, registryValue, snapshotValue, policyValue] = await Promise.all([ + readJson(options.manifest), + readJson(options.buyerGaps), + readJson(options.snapshot), + readJson(options.policy), + ]); + const manifest = validateCapabilityManifest(manifestValue); + const registry = validateBuyerGapRegistry(registryValue, manifest); + const snapshot = validateGitHubSnapshot(snapshotValue); + const policy = validateCommercialReadinessPolicy(policyValue); + const token = environment.GITHUB_TOKEN; + if (typeof token !== 'string' || !token.trim()) { + throw new Error('GitHub token is required'); + } + const client = new GitHubApiClient({ token }); + const gapSnapshot = await collectBuyerGapSnapshot( + client, + options.repository, + registry, + snapshot.generated_at, + ); + const buyerGapEvidence = evaluateBuyerGaps(registry, gapSnapshot); + const report = await evaluateCapabilities(manifest, { + rootDir: options.root, + generatedAt: snapshot.generated_at, + commitSha: snapshot.commit_sha, + buyerGapEvidence, + }); + const markdown = renderCommercialReadinessIssue(report, snapshot, { + marker: policy.readiness_issue_marker, + maxGaps: 20, + }); + await Promise.all([ + writeAtomic(options.outputJson, `${JSON.stringify(report, null, 2)}\n`), + writeAtomic(options.outputMarkdown, markdown), + ]); + return report; +} + +async function main(argv = process.argv.slice(2)) { + const options = parseBuyerGapArguments(argv); + const report = await runBuyerGapAudit(options); + console.log( + `audit: ${report.summary.capability_evidence_gaps} capability evidence gap(s), ${report.summary.unresolved_buyer_gaps} canonical buyer gap(s), ${report.summary.unknown_buyer_gap_states} unknown buyer-gap state(s)`, + ); +} + +const invokedPath = process.argv[1]; +if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { + main().catch((error) => { + console.error( + error instanceof Error ? error.message : 'Buyer gap audit failed', + ); + process.exitCode = 1; + }); +} diff --git a/packages/commercial-readiness/src/buyer-gap-cli.test.mjs b/packages/commercial-readiness/src/buyer-gap-cli.test.mjs new file mode 100644 index 00000000..37d7c4ec --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-cli.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parseBuyerGapArguments } from './buyer-gap-cli.mjs'; + +const validArguments = [ + '--repository', + 'ContextualWisdomLab/life-os', + '--manifest', + 'product/capabilities.json', + '--buyer-gaps', + 'product/buyer-gaps.json', + '--snapshot', + 'evidence/github-snapshot.json', + '--policy', + 'product/commercial-readiness-policy.json', + '--root', + '.', + '--output-json', + 'evidence/commercial-readiness.json', + '--output-markdown', + 'evidence/commercial-readiness.md', +]; + +describe('parseBuyerGapArguments', () => { + it('accepts the fixed bounded workflow surface', () => { + assert.deepEqual(parseBuyerGapArguments(validArguments), { + repository: 'ContextualWisdomLab/life-os', + manifest: 'product/capabilities.json', + buyerGaps: 'product/buyer-gaps.json', + snapshot: 'evidence/github-snapshot.json', + policy: 'product/commercial-readiness-policy.json', + root: '.', + outputJson: 'evidence/commercial-readiness.json', + outputMarkdown: 'evidence/commercial-readiness.md', + }); + }); + + it('rejects unknown, duplicate, missing, and control-character arguments', () => { + for (const argv of [ + validArguments.slice(0, -2), + [...validArguments, '--unknown', 'value'], + [...validArguments, '--root', '.'], + validArguments.map((value, index) => + index === 1 ? 'ContextualWisdomLab/life-os\nother' : value, + ), + ]) { + assert.throws( + () => parseBuyerGapArguments(argv), + /Invalid buyer gap audit command/, + ); + } + }); +}); diff --git a/packages/commercial-readiness/src/buyer-gap-report.test.mjs b/packages/commercial-readiness/src/buyer-gap-report.test.mjs new file mode 100644 index 00000000..035f58f9 --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-report.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; +import { attachBuyerGapEvidence } from './buyer-gaps.mjs'; +import { renderCommercialReadinessIssue } from './render.mjs'; + +const repositoryRoot = process.env.LIFE_OS_REPOSITORY_ROOT + ? resolve(process.env.LIFE_OS_REPOSITORY_ROOT) + : resolve(fileURLToPath(new URL('../../../', import.meta.url))); + +async function repositoryFile(path) { + return await readFile(resolve(repositoryRoot, path), 'utf8'); +} + +const baseReport = { + schema: 'life-os.commercial-readiness-report.v1', + generated_at: '2026-08-09T11:00:00.000Z', + commit_sha: 'a'.repeat(40), + summary: { + total_capabilities: 22, + at_target: 22, + unresolved_gaps: 0, + weighted_maturity_percent: 100, + }, + capabilities: [], + gaps: [], +}; + +const snapshot = { + pull_requests: [], +}; + +describe('canonical buyer-gap report', () => { + it('keeps 100 percent configured maturity separate from an open canonical product gap', () => { + const report = attachBuyerGapEvidence(baseReport, { + unresolved: [ + { + gap_id: 'calendar.per-user-credentials', + issue_number: 129, + capability_ids: ['calendar.time-blocking'], + state: 'open', + resolution: null, + }, + ], + resolved: [], + unknown: [], + }); + + assert.equal(report.summary.weighted_maturity_percent, 100); + assert.equal(report.summary.capability_evidence_gaps, 0); + assert.equal(report.summary.unresolved_buyer_gaps, 1); + const markdown = renderCommercialReadinessIssue(report, snapshot, { + marker: '', + maxGaps: 20, + }); + assert.match(markdown, /Configured weighted maturity: \*\*100%\*\*/); + assert.match(markdown, /Capability evidence gaps: \*\*0\*\*/); + assert.match(markdown, /Unresolved canonical buyer gaps: \*\*1\*\*/); + assert.match(markdown, /calendar\.per-user-credentials/); + assert.match(markdown, /#129/); + assert.doesNotMatch( + markdown, + /Unresolved canonical buyer gaps: \*\*0\*\*/, + ); + assert.doesNotMatch( + markdown, + /No registered canonical buyer gaps remain/, + ); + }); + + it('renders unknown canonical issue state explicitly instead of claiming exhaustion', () => { + const report = attachBuyerGapEvidence(baseReport, { + unresolved: [], + resolved: [], + unknown: [ + { + gap_id: 'plugins.runtime-delivery', + issue_number: 130, + capability_ids: ['integrations.plugin-surface'], + state: 'unknown', + resolution: null, + }, + ], + }); + const markdown = renderCommercialReadinessIssue(report, snapshot, { + marker: '', + }); + assert.match(markdown, /Unknown canonical buyer-gap states: \*\*1\*\*/); + assert.match(markdown, /state unknown/); + assert.doesNotMatch(markdown, /No registered canonical buyer gaps remain/); + }); + + it('wires the registry into the live commercial-readiness workflow', async () => { + const workflow = await repositoryFile( + '.github/workflows/commercial-readiness.yml', + ); + assert.match(workflow, /buyer-gap-cli\.mjs/); + assert.match(workflow, /--buyer-gaps product\/buyer-gaps\.json/); + assert.match(workflow, /issues:\s*read/); + assert.match(workflow, /GITHUB_TOKEN:\s*\$\{\{ github\.token \}\}/); + assert.doesNotMatch(workflow, /secrets:\s*inherit/); + }); +}); diff --git a/packages/commercial-readiness/src/buyer-gap-validation.test.mjs b/packages/commercial-readiness/src/buyer-gap-validation.test.mjs new file mode 100644 index 00000000..873a0e67 --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gap-validation.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + attachBuyerGapEvidence, + validateBuyerGapSnapshot, +} from './buyer-gaps.mjs'; + +function snapshot(overrides = {}) { + return { + schema: 'life-os.commercial-buyer-gap-snapshot.v1', + repository: 'ContextualWisdomLab/life-os', + generated_at: '2026-08-09T11:00:00.000Z', + issues: [ + { + number: 55, + state: 'closed', + state_reason: 'completed', + labels: [], + }, + ], + ...overrides, + }; +} + +describe('validateBuyerGapSnapshot', () => { + it('accepts and freezes a minimal external issue-state projection', () => { + const value = validateBuyerGapSnapshot(snapshot()); + assert.equal(value.repository, 'ContextualWisdomLab/life-os'); + assert.equal(Object.isFrozen(value), true); + assert.equal(Object.isFrozen(value.issues), true); + assert.equal(Object.isFrozen(value.issues[0].labels), true); + }); + + it('rejects raw bodies, duplicate evidence, malformed repositories, timestamps, and oversized collections', () => { + const issue = snapshot().issues[0]; + const invalid = [ + snapshot({ repository: 'https://example.test/repo' }), + snapshot({ generated_at: 2026 }), + snapshot({ generated_at: 'not-a-date' }), + snapshot({ issues: [{ ...issue, body: 'untrusted' }] }), + snapshot({ issues: [issue, issue] }), + snapshot({ issues: Array.from({ length: 101 }, (_, index) => ({ + number: index + 1, + state: 'open', + state_reason: null, + labels: [], + })) }), + snapshot({ + issues: [ + { + number: 55, + state: 'open', + state_reason: null, + labels: ['unsafe\nlabel'], + }, + ], + }), + ]; + for (const value of invalid) { + assert.throws( + () => validateBuyerGapSnapshot(value), + /Invalid buyer gap snapshot/, + ); + } + }); +}); + +describe('attachBuyerGapEvidence', () => { + it('preserves configured capability maturity while adding explicit product-gap dimensions', () => { + const report = { + schema: 'life-os.commercial-readiness-report.v1', + generated_at: '2026-08-09T11:00:00.000Z', + commit_sha: 'a'.repeat(40), + summary: { + total_capabilities: 22, + at_target: 22, + unresolved_gaps: 0, + weighted_maturity_percent: 100, + }, + capabilities: [], + gaps: [], + }; + const result = attachBuyerGapEvidence(report, { + unresolved: [ + { + gap_id: 'data.portability-completion', + issue_number: 55, + capability_ids: ['data.portability-rights'], + state: 'open', + resolution: null, + }, + ], + resolved: [], + unknown: [], + }); + + assert.equal(result.summary.weighted_maturity_percent, 100); + assert.equal(result.summary.unresolved_gaps, 0); + assert.equal(result.summary.capability_evidence_gaps, 0); + assert.equal(result.summary.unresolved_buyer_gaps, 1); + assert.equal(result.summary.unknown_buyer_gap_states, 0); + assert.equal(result.buyer_gaps[0].issue_number, 55); + }); +}); diff --git a/packages/commercial-readiness/src/buyer-gaps.mjs b/packages/commercial-readiness/src/buyer-gaps.mjs new file mode 100644 index 00000000..08978e2e --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gaps.mjs @@ -0,0 +1,379 @@ +const REGISTRY_SCHEMA = 'life-os.commercial-buyer-gaps.v1'; +const SNAPSHOT_SCHEMA = 'life-os.commercial-buyer-gap-snapshot.v1'; +const GAP_ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/; +const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const MAX_GAPS = 100; +const MAX_CAPABILITIES_PER_GAP = 25; +const MAX_LABELS = 50; +const MAX_LABEL_LENGTH = 100; + +function isPlainObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactKeys(value, allowed) { + return isPlainObject(value) && Object.keys(value).every((key) => allowed.has(key)); +} + +function failRegistry(detail = '') { + throw new Error(`Invalid buyer gap registry${detail ? `: ${detail}` : ''}`); +} + +function failSnapshot(detail = '') { + throw new Error(`Invalid buyer gap snapshot${detail ? `: ${detail}` : ''}`); +} + +function normalizeGapId(value) { + if (typeof value !== 'string' || !GAP_ID_PATTERN.test(value) || value.length > 100) { + failRegistry('invalid gap id'); + } + return value; +} + +function normalizeIssueNumber(value, fail = failRegistry) { + if (!Number.isSafeInteger(value) || value <= 0) { + fail('invalid issue number'); + } + return value; +} + +function manifestCapabilityIds(manifest) { + if (!isPlainObject(manifest) || !Array.isArray(manifest.capabilities)) { + failRegistry('invalid capability manifest'); + } + const ids = new Set(); + for (const capability of manifest.capabilities) { + const id = capability?.id; + if (typeof id !== 'string' || !CAPABILITY_ID_PATTERN.test(id)) { + failRegistry('invalid capability manifest'); + } + ids.add(id); + } + return ids; +} + +/** + * Validates repository-owned buyer-gap policy independently from capability + * maturity. GitHub issue title/body text is never executable product policy. + */ +export function validateBuyerGapRegistry(value, manifest) { + if ( + !exactKeys(value, new Set(['schema', 'gaps'])) || + value.schema !== REGISTRY_SCHEMA || + !Array.isArray(value.gaps) || + value.gaps.length === 0 || + value.gaps.length > MAX_GAPS + ) { + failRegistry(); + } + + const knownCapabilities = manifestCapabilityIds(manifest); + const gapIds = new Set(); + const issueNumbers = new Set(); + const gaps = value.gaps.map((entry) => { + if (!exactKeys(entry, new Set(['gap_id', 'issue_number', 'capability_ids']))) { + failRegistry('invalid gap entry'); + } + const gapId = normalizeGapId(entry.gap_id); + const issueNumber = normalizeIssueNumber(entry.issue_number); + if (gapIds.has(gapId)) failRegistry('duplicate gap id'); + if (issueNumbers.has(issueNumber)) failRegistry('duplicate canonical issue'); + gapIds.add(gapId); + issueNumbers.add(issueNumber); + + if ( + !Array.isArray(entry.capability_ids) || + entry.capability_ids.length === 0 || + entry.capability_ids.length > MAX_CAPABILITIES_PER_GAP + ) { + failRegistry('invalid capability collection'); + } + const capabilityIds = entry.capability_ids.map((capabilityId) => { + if ( + typeof capabilityId !== 'string' || + !CAPABILITY_ID_PATTERN.test(capabilityId) || + !knownCapabilities.has(capabilityId) + ) { + failRegistry('unknown or invalid capability id'); + } + return capabilityId; + }); + if (new Set(capabilityIds).size !== capabilityIds.length) { + failRegistry('duplicate capability id'); + } + + return Object.freeze({ + gap_id: gapId, + issue_number: issueNumber, + capability_ids: Object.freeze([...capabilityIds]), + }); + }); + + return Object.freeze({ + schema: REGISTRY_SCHEMA, + gaps: Object.freeze(gaps), + }); +} + +function normalizeLabel(value) { + const label = + typeof value === 'string' + ? value + : isPlainObject(value) && typeof value.name === 'string' + ? value.name + : null; + if ( + label === null || + !label.trim() || + label.length > MAX_LABEL_LENGTH || + /[\u0000-\u001f\u007f]/u.test(label) + ) { + failSnapshot('invalid issue label'); + } + return label.trim(); +} + +function normalizeIssueEvidence(value) { + if ( + !exactKeys( + value, + new Set(['number', 'state', 'state_reason', 'labels']), + ) + ) { + failSnapshot('invalid issue evidence'); + } + const number = normalizeIssueNumber(value.number, failSnapshot); + if (!['open', 'closed', 'unknown'].includes(value.state)) { + failSnapshot('invalid issue state'); + } + const stateReason = value.state_reason; + if ( + stateReason !== null && + stateReason !== undefined && + !['completed', 'not_planned', 'reopened'].includes(stateReason) + ) { + failSnapshot('invalid issue state reason'); + } + if (!Array.isArray(value.labels) || value.labels.length > MAX_LABELS) { + failSnapshot('invalid issue labels'); + } + const labels = value.labels.map(normalizeLabel); + if (new Set(labels).size !== labels.length) { + failSnapshot('duplicate issue label'); + } + return Object.freeze({ + number, + state: value.state, + state_reason: stateReason ?? null, + labels: Object.freeze(labels), + }); +} + +/** Validates the minimal live issue-state projection used by buyer-gap policy. */ +export function validateBuyerGapSnapshot(value) { + if ( + !exactKeys( + value, + new Set(['schema', 'repository', 'generated_at', 'issues']), + ) || + value.schema !== SNAPSHOT_SCHEMA || + typeof value.repository !== 'string' || + !REPOSITORY_PATTERN.test(value.repository) || + typeof value.generated_at !== 'string' || + !Number.isFinite(Date.parse(value.generated_at)) || + !Array.isArray(value.issues) || + value.issues.length > MAX_GAPS + ) { + failSnapshot(); + } + const issues = value.issues.map(normalizeIssueEvidence); + const seen = new Set(); + for (const issue of issues) { + if (seen.has(issue.number)) failSnapshot('duplicate issue evidence'); + seen.add(issue.number); + } + return Object.freeze({ + schema: SNAPSHOT_SCHEMA, + repository: value.repository, + generated_at: new Date(value.generated_at).toISOString(), + issues: Object.freeze(issues), + }); +} + +function projectedLabels(rawLabels) { + if (!Array.isArray(rawLabels) || rawLabels.length > MAX_LABELS) return []; + const labels = []; + for (const rawLabel of rawLabels) { + const label = + typeof rawLabel === 'string' + ? rawLabel + : isPlainObject(rawLabel) && typeof rawLabel.name === 'string' + ? rawLabel.name + : null; + if ( + label === null || + !label.trim() || + label.length > MAX_LABEL_LENGTH || + /[\u0000-\u001f\u007f]/u.test(label) + ) { + continue; + } + labels.push(label.trim()); + } + return [...new Set(labels)].sort(); +} + +/** + * Collects only the registered issue states. Individual fetch failures become + * explicit unknown evidence instead of silently resolving a product gap. + */ +export async function collectBuyerGapSnapshot( + client, + repository, + registry, + generatedAt = new Date().toISOString(), +) { + if ( + !client || + typeof client.requestJson !== 'function' || + typeof repository !== 'string' || + !REPOSITORY_PATTERN.test(repository) || + typeof generatedAt !== 'string' || + !Number.isFinite(Date.parse(generatedAt)) || + !registry || + registry.schema !== REGISTRY_SCHEMA || + !Array.isArray(registry.gaps) + ) { + throw new Error('Buyer gap snapshot collection input is invalid'); + } + + const issues = []; + for (const gap of [...registry.gaps].sort( + (left, right) => left.issue_number - right.issue_number, + )) { + try { + const issue = await client.requestJson( + `/repos/${repository}/issues/${gap.issue_number}`, + ); + if (issue?.pull_request) { + issues.push({ + number: gap.issue_number, + state: 'unknown', + state_reason: null, + labels: [], + }); + continue; + } + issues.push({ + number: gap.issue_number, + state: issue?.state === 'open' || issue?.state === 'closed' ? issue.state : 'unknown', + state_reason: + issue?.state_reason === 'completed' || + issue?.state_reason === 'not_planned' || + issue?.state_reason === 'reopened' + ? issue.state_reason + : null, + labels: projectedLabels(issue?.labels), + }); + } catch { + issues.push({ + number: gap.issue_number, + state: 'unknown', + state_reason: null, + labels: [], + }); + } + } + + return validateBuyerGapSnapshot({ + schema: SNAPSHOT_SCHEMA, + repository, + generated_at: generatedAt, + issues, + }); +} + +function resolutionFor(issue) { + const labels = new Set(issue.labels.map((label) => label.toLowerCase())); + if (labels.has('duplicate')) return 'duplicate'; + if (issue.state_reason === 'completed') return 'completed'; + if (issue.state_reason === 'not_planned') return 'not_planned'; + return null; +} + +function gapEvidence(gap, state, resolution = null) { + return { + gap_id: gap.gap_id, + issue_number: gap.issue_number, + capability_ids: [...gap.capability_ids], + state, + resolution, + }; +} + +/** + * Reconciles canonical product policy with bounded live issue state. Open gaps + * remain unresolved; missing or ambiguous evidence remains explicit unknown. + */ +export function evaluateBuyerGaps(registry, snapshot) { + const issues = new Map( + (Array.isArray(snapshot?.issues) ? snapshot.issues : []).map((issue) => [ + issue.number, + issue, + ]), + ); + const unresolved = []; + const resolved = []; + const unknown = []; + + for (const gap of [...registry.gaps].sort((left, right) => + left.gap_id.localeCompare(right.gap_id), + )) { + const issue = issues.get(gap.issue_number); + if (!issue || issue.state === 'unknown') { + unknown.push(gapEvidence(gap, 'unknown')); + continue; + } + if (issue.state === 'open') { + unresolved.push(gapEvidence(gap, 'open')); + continue; + } + const resolution = resolutionFor(issue); + if (issue.state === 'closed' && resolution !== null) { + resolved.push(gapEvidence(gap, 'closed', resolution)); + continue; + } + unknown.push(gapEvidence(gap, 'unknown')); + } + + const byIssue = (left, right) => + left.issue_number - right.issue_number || left.gap_id.localeCompare(right.gap_id); + unresolved.sort(byIssue); + resolved.sort(byIssue); + unknown.sort(byIssue); + return { unresolved, resolved, unknown }; +} + +/** Adds buyer-gap evidence without reinterpreting capability maturity. */ +export function attachBuyerGapEvidence(report, evidence) { + if (!isPlainObject(report) || !isPlainObject(report.summary)) { + throw new Error('Commercial readiness report is invalid'); + } + return { + ...report, + summary: { + ...report.summary, + capability_evidence_gaps: report.summary.unresolved_gaps, + unresolved_buyer_gaps: evidence.unresolved.length, + unknown_buyer_gap_states: evidence.unknown.length, + }, + buyer_gaps: evidence.unresolved.map((item) => ({ ...item })), + buyer_gap_unknown: evidence.unknown.map((item) => ({ ...item })), + buyer_gap_resolved: evidence.resolved.map((item) => ({ ...item })), + }; +} diff --git a/packages/commercial-readiness/src/buyer-gaps.test.mjs b/packages/commercial-readiness/src/buyer-gaps.test.mjs new file mode 100644 index 00000000..fe581255 --- /dev/null +++ b/packages/commercial-readiness/src/buyer-gaps.test.mjs @@ -0,0 +1,325 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + collectBuyerGapSnapshot, + evaluateBuyerGaps, + validateBuyerGapRegistry, + validateBuyerGapSnapshot, +} from './buyer-gaps.mjs'; + +const manifest = Object.freeze({ + schema: 'life-os.capability-manifest.v1', + capabilities: Object.freeze([ + Object.freeze({ id: 'planning.durable-data' }), + Object.freeze({ id: 'today.action-loop' }), + Object.freeze({ id: 'calendar.time-blocking' }), + Object.freeze({ id: 'integrations.plugin-surface' }), + ]), +}); + +function registry(gaps) { + return { + schema: 'life-os.commercial-buyer-gaps.v1', + gaps, + }; +} + +function gap(overrides = {}) { + return { + gap_id: 'today.multi-device-sync', + issue_number: 121, + capability_ids: ['planning.durable-data', 'today.action-loop'], + ...overrides, + }; +} + +function snapshot(issues) { + return { + schema: 'life-os.github-snapshot.v1', + repository: 'ContextualWisdomLab/life-os', + commit_sha: 'a'.repeat(40), + generated_at: '2026-08-09T11:00:00.000Z', + truncated: false, + pull_requests: [], + issues, + }; +} + +describe('validateBuyerGapRegistry', () => { + it('accepts a bounded repository-owned registry and freezes normalized entries', () => { + const result = validateBuyerGapRegistry(registry([gap()]), manifest); + assert.equal(result.schema, 'life-os.commercial-buyer-gaps.v1'); + assert.equal(result.gaps[0].gap_id, 'today.multi-device-sync'); + assert.deepEqual(result.gaps[0].capability_ids, [ + 'planning.durable-data', + 'today.action-loop', + ]); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.isFrozen(result.gaps), true); + assert.equal(Object.isFrozen(result.gaps[0].capability_ids), true); + }); + + it('rejects duplicate policy ownership, unknown capabilities, and malformed identifiers', () => { + const invalidRegistries = [ + registry([gap(), gap()]), + registry([ + gap(), + gap({ + gap_id: 'calendar.per-user-credentials', + capability_ids: ['calendar.time-blocking'], + }), + ]), + registry([gap({ capability_ids: ['missing.capability'] })]), + registry([gap({ gap_id: '121' })]), + registry([gap({ issue_number: '121' })]), + registry([gap({ capability_ids: [] })]), + registry([ + gap({ capability_ids: ['today.action-loop', 'today.action-loop'] }), + ]), + ]; + + for (const value of invalidRegistries) { + assert.throws( + () => validateBuyerGapRegistry(value, manifest), + /Invalid buyer gap registry/, + ); + } + }); +}); + +describe('validateBuyerGapSnapshot', () => { + it('rejects a non-string generated_at even when Date.parse would coerce it', () => { + assert.throws( + () => + validateBuyerGapSnapshot({ + schema: 'life-os.buyer-gap-snapshot.v1', + repository: 'ContextualWisdomLab/life-os', + generated_at: 2026, + issues: [], + }), + /Invalid buyer gap snapshot/, + ); + }); +}); + +describe('collectBuyerGapSnapshot', () => { + it('retains only bounded registered issue state and makes fetch failure unknown', async () => { + const validated = validateBuyerGapRegistry( + registry([ + gap(), + gap({ + gap_id: 'calendar.per-user-credentials', + issue_number: 129, + capability_ids: ['calendar.time-blocking'], + }), + ]), + manifest, + ); + const requested = []; + const client = { + async requestJson(path) { + requested.push(path); + if (path.endsWith('/121')) { + return { + number: 121, + title: 'untrusted title not retained', + body: 'untrusted body not retained', + state: 'open', + state_reason: null, + labels: [{ name: 'buyer-gap' }], + }; + } + throw new Error('provider unavailable'); + }, + }; + + const result = await collectBuyerGapSnapshot( + client, + 'ContextualWisdomLab/life-os', + validated, + '2026-08-09T11:00:00.000Z', + ); + + assert.deepEqual(requested, [ + '/repos/ContextualWisdomLab/life-os/issues/121', + '/repos/ContextualWisdomLab/life-os/issues/129', + ]); + assert.deepEqual(result.issues, [ + { + number: 121, + state: 'open', + state_reason: null, + labels: ['buyer-gap'], + }, + { number: 129, state: 'unknown', state_reason: null, labels: [] }, + ]); + assert.equal(JSON.stringify(result).includes('untrusted title'), false); + assert.equal(JSON.stringify(result).includes('untrusted body'), false); + }); + + it('rejects a non-string generatedAt before provider access', async () => { + const validated = validateBuyerGapRegistry(registry([gap()]), manifest); + let providerCalled = false; + const client = { + async requestJson() { + providerCalled = true; + return { state: 'open', labels: [] }; + }, + }; + + await assert.rejects( + collectBuyerGapSnapshot( + client, + 'ContextualWisdomLab/life-os', + validated, + 2026, + ), + /Buyer gap snapshot collection input is invalid/, + ); + assert.equal(providerCalled, false); + }); +}); + +describe('evaluateBuyerGaps', () => { + it('keeps an open canonical gap unresolved even when its capabilities are already mature', () => { + const validated = validateBuyerGapRegistry(registry([gap()]), manifest); + const result = evaluateBuyerGaps( + validated, + snapshot([ + { + number: 121, + title: 'Durable Today synchronization', + state: 'open', + state_reason: null, + labels: [], + }, + ]), + ); + + assert.equal(result.unresolved.length, 1); + assert.equal(result.unresolved[0].gap_id, 'today.multi-device-sync'); + assert.equal(result.unresolved[0].issue_number, 121); + assert.equal(result.unresolved[0].state, 'open'); + assert.deepEqual(result.unknown, []); + }); + + it('treats closed completed, duplicate-labeled, and not-planned issues as resolved', () => { + const validated = validateBuyerGapRegistry( + registry([ + gap(), + gap({ + gap_id: 'calendar.per-user-credentials', + issue_number: 129, + capability_ids: ['calendar.time-blocking'], + }), + gap({ + gap_id: 'plugins.runtime-delivery', + issue_number: 130, + capability_ids: ['integrations.plugin-surface'], + }), + ]), + manifest, + ); + const result = evaluateBuyerGaps( + validated, + snapshot([ + { + number: 121, + title: 'Today', + state: 'closed', + state_reason: 'completed', + labels: [], + }, + { + number: 129, + title: 'Calendar', + state: 'closed', + state_reason: null, + labels: ['duplicate'], + }, + { + number: 130, + title: 'Plugin', + state: 'closed', + state_reason: 'not_planned', + labels: [], + }, + ]), + ); + + assert.equal(result.unresolved.length, 0); + assert.equal(result.unknown.length, 0); + assert.deepEqual( + result.resolved.map((item) => [item.gap_id, item.resolution]), + [ + ['today.multi-device-sync', 'completed'], + ['calendar.per-user-credentials', 'duplicate'], + ['plugins.runtime-delivery', 'not_planned'], + ], + ); + }); + + it('fails closed to an explicit unknown state when registered issue evidence is missing', () => { + const validated = validateBuyerGapRegistry(registry([gap()]), manifest); + const result = evaluateBuyerGaps(validated, snapshot([])); + + assert.equal(result.unresolved.length, 0); + assert.equal(result.unknown.length, 1); + assert.deepEqual(result.unknown[0], { + gap_id: 'today.multi-device-sync', + issue_number: 121, + capability_ids: ['planning.durable-data', 'today.action-loop'], + state: 'unknown', + resolution: null, + }); + }); + + it('sorts evidence deterministically and ignores unregistered ordinary issues', () => { + const validated = validateBuyerGapRegistry( + registry([ + gap({ + gap_id: 'plugins.runtime-delivery', + issue_number: 130, + capability_ids: ['integrations.plugin-surface'], + }), + gap(), + ]), + manifest, + ); + const result = evaluateBuyerGaps( + validated, + snapshot([ + { + number: 999, + title: 'Ordinary issue', + state: 'open', + state_reason: null, + labels: [], + }, + { + number: 130, + title: 'Plugin runtime', + state: 'open', + state_reason: null, + labels: [], + }, + { + number: 121, + title: 'Today sync', + state: 'open', + state_reason: null, + labels: [], + }, + ]), + ); + + assert.deepEqual( + result.unresolved.map((item) => item.issue_number), + [121, 130], + ); + assert.equal( + result.unresolved.some((item) => item.issue_number === 999), + false, + ); + }); +}); diff --git a/packages/commercial-readiness/src/exact-head-workflow.test.mjs b/packages/commercial-readiness/src/exact-head-workflow.test.mjs new file mode 100644 index 00000000..28e1d21b --- /dev/null +++ b/packages/commercial-readiness/src/exact-head-workflow.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const repositoryRoot = process.env.LIFE_OS_REPOSITORY_ROOT + ? resolve(process.env.LIFE_OS_REPOSITORY_ROOT) + : resolve(fileURLToPath(new URL('../../../', import.meta.url))); + +describe('commercial readiness exact-head contract', () => { + it('binds PR checkout and evidence commit to the contributor head rather than the synthetic merge SHA', async () => { + const workflow = await readFile( + resolve(repositoryRoot, '.github/workflows/commercial-readiness.yml'), + 'utf8', + ); + const sourceExpression = + '\\$\\{\\{ github\\.event\\.pull_request\\.head\\.sha \\|\\| github\\.sha \\}\\}'; + assert.match(workflow, new RegExp(`ref: ${sourceExpression}`)); + assert.match( + workflow, + new RegExp(`--commit "${sourceExpression}"`), + ); + assert.doesNotMatch(workflow, /--commit "\$GITHUB_SHA"/); + }); +}); diff --git a/packages/commercial-readiness/src/render.mjs b/packages/commercial-readiness/src/render.mjs index e244d691..03051749 100644 --- a/packages/commercial-readiness/src/render.mjs +++ b/packages/commercial-readiness/src/render.mjs @@ -31,48 +31,112 @@ function issueLink(number) { : 'untracked'; } +function capabilityList(capabilityIds) { + return (Array.isArray(capabilityIds) ? capabilityIds : []) + .map((id) => `\`${sanitizeUntrustedText(id)}\``) + .join(', '); +} + +function renderCanonicalBuyerGaps(lines, report, maxGaps) { + const hasBuyerEvidence = Number.isSafeInteger( + report.summary?.unresolved_buyer_gaps, + ); + lines.push('## Canonical buyer-visible gaps', ''); + if (!hasBuyerEvidence) { + lines.push( + 'Canonical buyer-gap state was not evaluated in this report; capability maturity must not be interpreted as whole-product gap exhaustion.', + '', + ); + return; + } + + const unresolved = Array.isArray(report.buyer_gaps) ? report.buyer_gaps : []; + const unknown = Array.isArray(report.buyer_gap_unknown) + ? report.buyer_gap_unknown + : []; + if (unresolved.length === 0 && unknown.length === 0) { + lines.push( + 'No registered canonical buyer gaps remain open or unknown.', + '', + ); + return; + } + for (const gap of unresolved.slice(0, maxGaps)) { + lines.push( + `- **${sanitizeUntrustedText(gap.gap_id)}** — ${issueLink(gap.issue_number)} — open`, + ` - Capability links: ${capabilityList(gap.capability_ids) || 'none'}`, + ); + } + for (const gap of unknown.slice(0, maxGaps)) { + lines.push( + `- **${sanitizeUntrustedText(gap.gap_id)}** — ${issueLink(gap.issue_number)} — **state unknown**`, + ` - Capability links: ${capabilityList(gap.capability_ids) || 'none'}`, + ); + } + lines.push(''); +} + +function renderCapabilityEvidenceGaps(lines, report, maxGaps) { + lines.push('## Capability evidence gaps', ''); + if (!Array.isArray(report.gaps) || report.gaps.length === 0) { + lines.push( + 'No capability evidence gaps remain at the configured target maturity levels.', + '', + ); + return; + } + for (const gap of report.gaps.slice(0, maxGaps)) { + lines.push( + `### ${sanitizeUntrustedText(gap.capability_id)} · score ${gap.priority_score}`, + '', + `- Outcome: ${sanitizeUntrustedText(gap.outcome)}`, + `- Maturity: \`${gap.observed_maturity}\` → \`${gap.target_maturity}\``, + `- Tracking: ${issueLink(gap.tracking_issue)}`, + `- Missing evidence: ${ + gap.missing_evidence + .map((path) => `\`${sanitizeUntrustedText(path)}\``) + .join(', ') || 'none recorded' + }`, + '', + ); + } +} + export function renderCommercialReadinessIssue( report, snapshot, { marker, maxGaps = 15 }, ) { + const capabilityEvidenceGaps = Number.isSafeInteger( + report.summary?.capability_evidence_gaps, + ) + ? report.summary.capability_evidence_gaps + : report.summary.unresolved_gaps; const lines = [ marker, '# LifeOS commercial readiness', '', - '> Generated from repository evidence. Documentation claims do not satisfy implementation or test probes.', + '> Generated from repository evidence. Documentation claims do not satisfy implementation or test probes. Capability maturity and canonical buyer-gap state are independent evidence dimensions.', '', `- Commit: \`${report.commit_sha}\``, `- Evidence timestamp: \`${report.generated_at}\``, - `- Weighted maturity: **${report.summary.weighted_maturity_percent}%**`, + `- Configured weighted maturity: **${report.summary.weighted_maturity_percent}%**`, `- Capabilities at target: **${report.summary.at_target}/${report.summary.total_capabilities}**`, - `- Unresolved buyer gaps: **${report.summary.unresolved_gaps}**`, - '', - '## Highest-impact buyer gaps', - '', + `- Capability evidence gaps: **${capabilityEvidenceGaps}**`, ]; - if (report.gaps.length === 0) { + if (Number.isSafeInteger(report.summary?.unresolved_buyer_gaps)) { lines.push( - 'No evidence-backed capability gaps remain at the current target levels.', + `- Unresolved canonical buyer gaps: **${report.summary.unresolved_buyer_gaps}**`, + `- Unknown canonical buyer-gap states: **${report.summary.unknown_buyer_gap_states}**`, ); } else { - for (const gap of report.gaps.slice(0, maxGaps)) { - lines.push( - `### ${sanitizeUntrustedText(gap.capability_id)} · score ${gap.priority_score}`, - '', - `- Outcome: ${sanitizeUntrustedText(gap.outcome)}`, - `- Maturity: \`${gap.observed_maturity}\` → \`${gap.target_maturity}\``, - `- Tracking: ${issueLink(gap.tracking_issue)}`, - `- Missing evidence: ${ - gap.missing_evidence - .map((path) => `\`${sanitizeUntrustedText(path)}\``) - .join(', ') || 'none recorded' - }`, - '', - ); - } + lines.push('- Canonical buyer-gap evidence: **not evaluated**'); } + lines.push(''); + + renderCanonicalBuyerGaps(lines, report, maxGaps); + renderCapabilityEvidenceGaps(lines, report, maxGaps); lines.push('## Pull request drain', ''); const pulls = Array.isArray(snapshot.pull_requests) diff --git a/product/buyer-gaps.json b/product/buyer-gaps.json new file mode 100644 index 00000000..92a7aca6 --- /dev/null +++ b/product/buyer-gaps.json @@ -0,0 +1,25 @@ +{ + "schema": "life-os.commercial-buyer-gaps.v1", + "gaps": [ + { + "gap_id": "data.portability-completion", + "issue_number": 55, + "capability_ids": ["data.portability-rights"] + }, + { + "gap_id": "today.multi-device-sync", + "issue_number": 121, + "capability_ids": ["planning.durable-data", "today.action-loop"] + }, + { + "gap_id": "calendar.per-user-credentials", + "issue_number": 129, + "capability_ids": ["calendar.time-blocking"] + }, + { + "gap_id": "plugins.runtime-delivery", + "issue_number": 130, + "capability_ids": ["integrations.plugin-surface"] + } + ] +}