Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5f25fac
test(readiness): define canonical buyer-gap registry contract
seonghobae Aug 9, 2026
1b33b3d
feat(readiness): evaluate canonical buyer-gap evidence
seonghobae Aug 9, 2026
cc29d03
feat(readiness): register canonical buyer-visible gaps
seonghobae Aug 9, 2026
3378b83
feat(readiness): attach canonical buyer-gap evidence
seonghobae Aug 9, 2026
c52c9a7
feat(readiness): add live buyer-gap audit entrypoint
seonghobae Aug 9, 2026
3976017
fix(readiness): render capability and buyer gaps separately
seonghobae Aug 9, 2026
d089b6b
build(readiness): verify buyer-gap audit modules
seonghobae Aug 9, 2026
c9d455e
ci(readiness): reconcile canonical buyer-gap state
seonghobae Aug 9, 2026
4618273
test(readiness): cover bounded buyer-gap collection
seonghobae Aug 9, 2026
5e8b0b4
test(readiness): verify separated buyer-gap reporting
seonghobae Aug 9, 2026
788b02f
test(readiness): verify buyer-gap CLI boundary
seonghobae Aug 9, 2026
757fdf3
test(readiness): harden buyer-gap snapshot validation
seonghobae Aug 9, 2026
fd9d046
test(readiness): preserve capability maturity under buyer-gap evidence
seonghobae Aug 9, 2026
f657adb
fix(readiness): bind audit evidence to exact PR head
seonghobae Aug 9, 2026
197e4f0
test(readiness): require exact-head PR audit checkout
seonghobae Aug 9, 2026
a48f90b
style(readiness): format buyer-gap renderer
seonghobae Aug 9, 2026
ee620a5
test(readiness): assert canonical buyer-gap exhaustion failures
seonghobae Aug 9, 2026
a9b488d
test(readiness): reject coerced buyer-gap timestamps
seonghobae Aug 9, 2026
c1468c8
docs(readiness): explain capability and buyer-gap report contract
seonghobae Aug 9, 2026
1b01adb
test(readiness): reject non-string buyer-gap timestamps
seonghobae Aug 9, 2026
7d92de1
fix(readiness): require string buyer-gap timestamps
seonghobae Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/commercial-readiness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 . \
Expand Down
6 changes: 3 additions & 3 deletions packages/commercial-readiness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
27 changes: 25 additions & 2 deletions packages/commercial-readiness/src/audit.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<object>} immutable-input-derived commercial readiness report
*/
export async function evaluateCapabilities(
manifest,
{ rootDir, generatedAt, commitSha },
{ rootDir, generatedAt, commitSha, buyerGapEvidence },
Comment thread
seonghobae marked this conversation as resolved.
) {
if (typeof rootDir !== 'string' || !rootDir)
throw new Error('Repository root is required');
Expand Down Expand Up @@ -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(),
Expand All @@ -212,4 +231,8 @@ export async function evaluateCapabilities(
capabilities,
gaps,
};

return buyerGapEvidence === undefined
? report
: attachBuyerGapEvidence(report, buyerGapEvidence);
}
73 changes: 73 additions & 0 deletions packages/commercial-readiness/src/buyer-gap-audit.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
141 changes: 141 additions & 0 deletions packages/commercial-readiness/src/buyer-gap-cli.mjs
Original file line number Diff line number Diff line change
@@ -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;
});
}
53 changes: 53 additions & 0 deletions packages/commercial-readiness/src/buyer-gap-cli.test.mjs
Original file line number Diff line number Diff line change
@@ -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/,
);
}
});
});
Loading
Loading