@nebulacomponents/citable 1.18.0 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/dist/universal/.agents/skills/citable/SKILL.md +22 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.agents/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.agents/skills/citable/commands/README.md +13 -0
- package/dist/universal/.agents/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.agents/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.agents/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.agents/skills/citable/manifest.json +12 -8
- package/dist/universal/.agents/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.claude/agents/citable/citable-remediator.md +43 -0
- package/dist/universal/.claude/agents/citable/citable-sow-architect.md +43 -0
- package/dist/universal/.claude/agents/citable/manifest.json +6 -4
- package/dist/universal/.claude/skills/citable/SKILL.md +22 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.claude/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.claude/skills/citable/commands/README.md +13 -0
- package/dist/universal/.claude/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.claude/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.claude/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.claude/skills/citable/manifest.json +16 -10
- package/dist/universal/.claude/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.cursor/skills/citable/SKILL.md +22 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.cursor/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.cursor/skills/citable/commands/README.md +13 -0
- package/dist/universal/.cursor/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.cursor/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.cursor/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +12 -8
- package/dist/universal/.cursor/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.gemini/skills/citable/SKILL.md +22 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.gemini/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.gemini/skills/citable/commands/README.md +13 -0
- package/dist/universal/.gemini/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.gemini/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.gemini/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +12 -8
- package/dist/universal/.gemini/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.github/skills/citable/SKILL.md +22 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.github/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.github/skills/citable/commands/README.md +13 -0
- package/dist/universal/.github/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.github/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.github/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.github/skills/citable/manifest.json +12 -8
- package/dist/universal/.github/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.kiro/skills/citable/SKILL.md +22 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.kiro/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.kiro/skills/citable/commands/README.md +13 -0
- package/dist/universal/.kiro/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.kiro/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.kiro/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +12 -8
- package/dist/universal/.kiro/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.opencode/skills/citable/SKILL.md +22 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.opencode/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.opencode/skills/citable/commands/README.md +13 -0
- package/dist/universal/.opencode/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.opencode/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.opencode/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +12 -8
- package/dist/universal/.opencode/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +22 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.pi/agent/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +13 -0
- package/dist/universal/.pi/agent/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.pi/agent/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +12 -8
- package/dist/universal/.pi/agent/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.qoder/skills/citable/SKILL.md +22 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.qoder/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.qoder/skills/citable/commands/README.md +13 -0
- package/dist/universal/.qoder/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.qoder/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.qoder/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +12 -8
- package/dist/universal/.qoder/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.rovodev/skills/citable/SKILL.md +22 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.rovodev/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.rovodev/skills/citable/commands/README.md +13 -0
- package/dist/universal/.rovodev/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.rovodev/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.rovodev/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +12 -8
- package/dist/universal/.rovodev/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.trae/skills/citable/SKILL.md +22 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.trae/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.trae/skills/citable/commands/README.md +13 -0
- package/dist/universal/.trae/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.trae/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.trae/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.trae/skills/citable/manifest.json +12 -8
- package/dist/universal/.trae/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +22 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.trae-cn/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +13 -0
- package/dist/universal/.trae-cn/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.trae-cn/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +12 -8
- package/dist/universal/.trae-cn/skills/citable/schemas/sow.schema.json +342 -25
- package/dist/universal/manifest.json +150 -100
- package/package.json +1 -1
- package/schemas/sow.schema.json +342 -25
- package/skill/SKILL.md +22 -1
- package/skill/agents/citable-remediator.md +43 -0
- package/skill/agents/citable-sow-architect.md +43 -0
- package/skill/commands/README.md +13 -0
- package/skill/commands/remediation.md +55 -0
- package/skill/commands/reporting.md +28 -0
- package/skill/commands/sow.md +95 -0
- package/src/commands/sowCmd.js +17 -0
- package/src/sow/admissibilityGate.js +216 -46
- package/src/sow/generateSow.js +529 -113
package/src/sow/generateSow.js
CHANGED
|
@@ -4,143 +4,503 @@ import { buildContext } from '../commands/context.js';
|
|
|
4
4
|
import { selectDetectors } from '../detectors/index.js';
|
|
5
5
|
import { runDetectors, indexTargets } from '../detectors/framework.js';
|
|
6
6
|
import { evaluateScopeAdmissibility } from './admissibilityGate.js';
|
|
7
|
-
import { readJson, nowIso } from '../shared/io.js';
|
|
7
|
+
import { readJson, nowIso, sha256 } from '../shared/io.js';
|
|
8
|
+
import { validateAgainst } from '../shared/schemaValidator.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Typed SOW Domain Errors
|
|
12
|
+
*/
|
|
13
|
+
export class SowError extends Error {
|
|
14
|
+
constructor(message, code = 'SOW_ERROR') {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = this.constructor.name;
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RunNotFoundError extends SowError {
|
|
22
|
+
constructor(runId) {
|
|
23
|
+
super(`Specified run not found: ${runId}`, 'RUN_NOT_FOUND');
|
|
24
|
+
this.runId = runId;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class FindingsMissingError extends SowError {
|
|
29
|
+
constructor(runId, filePath) {
|
|
30
|
+
super(`Run ${runId} is missing findings.json at ${filePath}`, 'FINDINGS_MISSING');
|
|
31
|
+
this.runId = runId;
|
|
32
|
+
this.filePath = filePath;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class FindingsInvalidError extends SowError {
|
|
37
|
+
constructor(runId, message) {
|
|
38
|
+
super(`Run ${runId} contains malformed or invalid findings: ${message}`, 'FINDINGS_INVALID');
|
|
39
|
+
this.runId = runId;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class LiveInspectionFailedError extends SowError {
|
|
44
|
+
constructor(target, reason) {
|
|
45
|
+
super(`Live target inspection failed for ${target}: ${reason}`, 'LIVE_INSPECTION_FAILED');
|
|
46
|
+
this.target = target;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class NoFindingsError extends SowError {
|
|
51
|
+
constructor(source) {
|
|
52
|
+
super(`No audit findings available from ${source}. Production SOW generation requires authoritative findings. (Use --sample to generate non-contractual demonstration SOW)`, 'NO_FINDINGS');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class NoAdmissibleRequirementsError extends SowError {
|
|
57
|
+
constructor(totalEvaluated, refusalCount) {
|
|
58
|
+
super(`Scope admissibility gate refused all ${totalEvaluated} findings (${refusalCount} refused). Zero contractable requirements remain.`, 'NO_ADMISSIBLE_REQUIREMENTS');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class BudgetCalculationError extends SowError {
|
|
63
|
+
constructor(message) {
|
|
64
|
+
super(message, 'INVALID_COMMERCIAL_TERMS');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class SowInvariantError extends SowError {
|
|
69
|
+
constructor(message) {
|
|
70
|
+
super(`SOW invariant failure: ${message}`, 'INVARIANT_VIOLATION');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const SOW_CURRENCY = 'USD';
|
|
75
|
+
export const SOW_CURRENCY_MINOR_EXPONENT = 2;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse commercial budget into integer minor units (cents) and major units (USD).
|
|
79
|
+
* Ensures money units are unambiguous and rejects invalid, negative, or sub-cent fractional budgets.
|
|
80
|
+
*/
|
|
81
|
+
export function parseCommercialBudget({ budget = 45000, budgetMinor = null } = {}) {
|
|
82
|
+
if (budgetMinor !== null && budgetMinor !== undefined) {
|
|
83
|
+
if (!Number.isInteger(budgetMinor) || budgetMinor < 0) {
|
|
84
|
+
throw new BudgetCalculationError(`budgetMinor must be a non-negative integer (cents), got: ${budgetMinor}`);
|
|
85
|
+
}
|
|
86
|
+
const feeUsd = Number((budgetMinor / 100).toFixed(2));
|
|
87
|
+
return {
|
|
88
|
+
feeMinor: budgetMinor,
|
|
89
|
+
feeUsd,
|
|
90
|
+
currency: SOW_CURRENCY,
|
|
91
|
+
minorUnitExponent: SOW_CURRENCY_MINOR_EXPONENT,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const num = Number(budget);
|
|
96
|
+
if (Number.isNaN(num) || !Number.isFinite(num) || num < 0) {
|
|
97
|
+
throw new BudgetCalculationError(`Commercial budget must be a non-negative number, got: ${budget}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const minorUnits = Math.round(num * 100);
|
|
101
|
+
if (Math.abs(num * 100 - minorUnits) > 1e-6) {
|
|
102
|
+
throw new BudgetCalculationError(`Commercial budget cannot contain fractional minor units (sub-cents): ${budget}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const feeUsd = Number((minorUnits / 100).toFixed(2));
|
|
106
|
+
return {
|
|
107
|
+
feeMinor: minorUnits,
|
|
108
|
+
feeUsd,
|
|
109
|
+
currency: SOW_CURRENCY,
|
|
110
|
+
minorUnitExponent: SOW_CURRENCY_MINOR_EXPONENT,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Exact integer milestone fee allocation distributing remainder deterministically.
|
|
116
|
+
* Allocates integer currency units (such as minor units / cents) across work packages without remainder leaks.
|
|
117
|
+
*/
|
|
118
|
+
export function allocateMilestoneFees(totalUnits, count) {
|
|
119
|
+
if (!Number.isInteger(totalUnits) || totalUnits < 0) {
|
|
120
|
+
throw new BudgetCalculationError(`Commercial budget units must be a non-negative integer, got: ${totalUnits}`);
|
|
121
|
+
}
|
|
122
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
123
|
+
throw new BudgetCalculationError(`Cannot allocate milestone fees across ${count} work packages`);
|
|
124
|
+
}
|
|
125
|
+
const base = Math.floor(totalUnits / count);
|
|
126
|
+
const remainder = totalUnits - (base * count);
|
|
127
|
+
const fees = [];
|
|
128
|
+
for (let i = 0; i < count; i++) {
|
|
129
|
+
fees.push(base + (i < remainder ? 1 : 0));
|
|
130
|
+
}
|
|
131
|
+
const sum = fees.reduce((acc, f) => acc + f, 0);
|
|
132
|
+
if (sum !== totalUnits) {
|
|
133
|
+
throw new BudgetCalculationError(`Milestone fee sum (${sum}) does not equal total units (${totalUnits})`);
|
|
134
|
+
}
|
|
135
|
+
return fees;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Extract canonical timestamp (epoch ms) from run package
|
|
140
|
+
* Hierarchy:
|
|
141
|
+
* 1. manifest.json `timestamp` or `created_at` ISO string
|
|
142
|
+
* 2. Run ID timestamp prefix (YYYYMMDDTHHmmss)
|
|
143
|
+
* 3. findings.json mtime
|
|
144
|
+
* 4. run directory mtime
|
|
145
|
+
*/
|
|
146
|
+
export function getCanonicalRunTimestamp(runDir, runId = '') {
|
|
147
|
+
// 1. manifest.json
|
|
148
|
+
const manifestPath = path.join(runDir, 'manifest.json');
|
|
149
|
+
if (fs.existsSync(manifestPath)) {
|
|
150
|
+
try {
|
|
151
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
152
|
+
if (manifest.timestamp) {
|
|
153
|
+
const ms = Date.parse(manifest.timestamp);
|
|
154
|
+
if (!Number.isNaN(ms)) return ms;
|
|
155
|
+
}
|
|
156
|
+
if (manifest.created_at) {
|
|
157
|
+
const ms = Date.parse(manifest.created_at);
|
|
158
|
+
if (!Number.isNaN(ms)) return ms;
|
|
159
|
+
}
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. Run ID timestamp prefix (YYYYMMDDTHHmmss)
|
|
164
|
+
const m = String(runId).match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/);
|
|
165
|
+
if (m) {
|
|
166
|
+
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
|
167
|
+
const ms = Date.parse(iso);
|
|
168
|
+
if (!Number.isNaN(ms)) return ms;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 3. findings.json mtime
|
|
172
|
+
const findPath = path.join(runDir, 'findings.json');
|
|
173
|
+
if (fs.existsSync(findPath)) {
|
|
174
|
+
try {
|
|
175
|
+
return fs.statSync(findPath).mtimeMs;
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 4. Directory mtime
|
|
180
|
+
try {
|
|
181
|
+
return fs.statSync(runDir).mtimeMs;
|
|
182
|
+
} catch {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Sort run candidate directory names by chronological timestamp descending,
|
|
189
|
+
* with deterministic lexicographical tie-breaking.
|
|
190
|
+
* Invariant:
|
|
191
|
+
* latest run = max(canonical run timestamp)
|
|
192
|
+
* tie = deterministic secondary key
|
|
193
|
+
*/
|
|
194
|
+
export function sortRunCandidatesChronologically(runsDir, candidateRunIds = []) {
|
|
195
|
+
return [...candidateRunIds].sort((a, b) => {
|
|
196
|
+
const timeA = getCanonicalRunTimestamp(path.join(runsDir, a), a);
|
|
197
|
+
const timeB = getCanonicalRunTimestamp(path.join(runsDir, b), b);
|
|
198
|
+
if (timeB !== timeA) {
|
|
199
|
+
return timeB - timeA; // Descending: latest timestamp first
|
|
200
|
+
}
|
|
201
|
+
return b.localeCompare(a); // Deterministic tie-breaker
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Verify cross-object runtime invariants before SOW export
|
|
207
|
+
*/
|
|
208
|
+
export function validateSowInvariants(sow) {
|
|
209
|
+
if (!sow || typeof sow !== 'object') {
|
|
210
|
+
throw new SowInvariantError('SOW root must be an object');
|
|
211
|
+
}
|
|
212
|
+
if (!['CONTRACTUAL', 'DRAFT', 'NON_CONTRACTUAL_SAMPLE'].includes(sow.generation_mode)) {
|
|
213
|
+
throw new SowInvariantError(`Invalid generation_mode: ${sow.generation_mode}`);
|
|
214
|
+
}
|
|
215
|
+
if (sow.generation_mode === 'CONTRACTUAL' && sow.synthetic_evidence) {
|
|
216
|
+
throw new SowInvariantError('Contractual SOW cannot contain synthetic evidence');
|
|
217
|
+
}
|
|
218
|
+
if (sow.currency !== 'USD') {
|
|
219
|
+
throw new SowInvariantError(`Currency must be 'USD', got: ${sow.currency}`);
|
|
220
|
+
}
|
|
221
|
+
if (sow.currency_minor_unit_exponent !== 2) {
|
|
222
|
+
throw new SowInvariantError(`Minor unit exponent must be 2, got: ${sow.currency_minor_unit_exponent}`);
|
|
223
|
+
}
|
|
224
|
+
if (typeof sow.commercial_total_fee_minor !== 'number' || !Number.isInteger(sow.commercial_total_fee_minor)) {
|
|
225
|
+
throw new SowInvariantError(`commercial_total_fee_minor must be an integer, got: ${sow.commercial_total_fee_minor}`);
|
|
226
|
+
}
|
|
227
|
+
if (typeof sow.commercial_total_fee_usd !== 'number' || sow.commercial_total_fee_usd < 0) {
|
|
228
|
+
throw new SowInvariantError(`commercial_total_fee_usd must be a non-negative number, got: ${sow.commercial_total_fee_usd}`);
|
|
229
|
+
}
|
|
230
|
+
if (sow.commercial_total_fee_minor !== Math.round(sow.commercial_total_fee_usd * 100)) {
|
|
231
|
+
throw new SowInvariantError(`commercial_total_fee_minor (${sow.commercial_total_fee_minor}) does not match commercial_total_fee_usd (${sow.commercial_total_fee_usd})`);
|
|
232
|
+
}
|
|
233
|
+
if (!Array.isArray(sow.work_packages) || sow.work_packages.length === 0) {
|
|
234
|
+
throw new SowInvariantError('SOW must contain at least one work package');
|
|
235
|
+
}
|
|
236
|
+
if (!Array.isArray(sow.deliverables) || sow.deliverables.length === 0) {
|
|
237
|
+
throw new SowInvariantError('SOW must contain at least one deliverable');
|
|
238
|
+
}
|
|
239
|
+
if (sow.work_packages.length !== sow.deliverables.length) {
|
|
240
|
+
throw new SowInvariantError(`Work packages count (${sow.work_packages.length}) must match deliverables count (${sow.deliverables.length})`);
|
|
241
|
+
}
|
|
242
|
+
if (!Array.isArray(sow.traceability_matrix) || sow.traceability_matrix.length === 0) {
|
|
243
|
+
throw new SowInvariantError('SOW must contain at least one traceability matrix requirement');
|
|
244
|
+
}
|
|
245
|
+
const wpIds = new Set(sow.work_packages.map((wp) => wp.work_package_id));
|
|
246
|
+
const delivIds = new Set(sow.deliverables.map((d) => d.deliverable_id));
|
|
247
|
+
for (const row of sow.traceability_matrix) {
|
|
248
|
+
if (!wpIds.has(row.work_package_id)) {
|
|
249
|
+
throw new SowInvariantError(`Traceability matrix references unknown work package: ${row.work_package_id}`);
|
|
250
|
+
}
|
|
251
|
+
if (!delivIds.has(row.deliverable_id)) {
|
|
252
|
+
throw new SowInvariantError(`Traceability matrix references unknown deliverable: ${row.deliverable_id}`);
|
|
253
|
+
}
|
|
254
|
+
if (!Array.isArray(row.evidence_ids) || row.evidence_ids.length === 0) {
|
|
255
|
+
throw new SowInvariantError(`Traceability matrix row ${row.sow_requirement_id} lacks supporting evidence_ids`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const milestones = sow.delivery_schedule?.milestones || [];
|
|
260
|
+
const milestoneMinorSum = milestones.reduce((sum, m) => sum + m.fee_minor, 0);
|
|
261
|
+
if (milestoneMinorSum !== sow.commercial_total_fee_minor) {
|
|
262
|
+
throw new SowInvariantError(`Milestone minor fees sum (${milestoneMinorSum}) does not match total minor fee (${sow.commercial_total_fee_minor})`);
|
|
263
|
+
}
|
|
264
|
+
const milestoneUsdSum = Number(milestones.reduce((sum, m) => sum + m.fee_usd, 0).toFixed(2));
|
|
265
|
+
if (milestoneUsdSum !== sow.commercial_total_fee_usd) {
|
|
266
|
+
throw new SowInvariantError(`Milestone USD fees sum (${milestoneUsdSum}) does not match total fee USD (${sow.commercial_total_fee_usd})`);
|
|
267
|
+
}
|
|
268
|
+
for (const m of milestones) {
|
|
269
|
+
if (m.fee_minor !== Math.round(m.fee_usd * 100)) {
|
|
270
|
+
throw new SowInvariantError(`Milestone ${m.milestone_id} fee_minor (${m.fee_minor}) does not match fee_usd (${m.fee_usd})`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const gate = sow.admissibility_gate;
|
|
275
|
+
if (gate.total_findings_evaluated !== gate.admitted_count + gate.refused_count) {
|
|
276
|
+
throw new SowInvariantError(`Admissibility gate numbers do not balance: ${gate.total_findings_evaluated} != ${gate.admitted_count} + ${gate.refused_count}`);
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Standard synthetic baseline findings used strictly for sample mode (--sample / --demo)
|
|
283
|
+
*/
|
|
284
|
+
export function getSampleBaselineFindings() {
|
|
285
|
+
return [
|
|
286
|
+
{
|
|
287
|
+
finding_id: 'F-TECH-CWV-001',
|
|
288
|
+
detector_id: 'TECH-001',
|
|
289
|
+
discipline: ['technical'],
|
|
290
|
+
classification: { severity: 'critical', confidence: 'deterministic' },
|
|
291
|
+
subject: { identifier: 'https://example.test/', url: 'https://example.test/' },
|
|
292
|
+
observation: { summary: 'Render-blocking JavaScript bundle degrades Mobile LCP to 4.2s', evidence: ['EVD-CWV-001'] },
|
|
293
|
+
remediation: { preferred: 'Implement asynchronous resource loading and preconnect headers' },
|
|
294
|
+
verification: { detector_to_rerun: 'TECH-001', method: 'Static CWV inspection and HTTP header probe' },
|
|
295
|
+
ice_score: 14.5,
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
finding_id: 'F-CRO-AUTO-007',
|
|
299
|
+
detector_id: 'CRO-007',
|
|
300
|
+
discipline: ['cro'],
|
|
301
|
+
classification: { severity: 'high', confidence: 'deterministic' },
|
|
302
|
+
subject: { identifier: 'https://example.test/checkout', url: 'https://example.test/checkout' },
|
|
303
|
+
observation: { summary: 'Checkout form inputs lack HTML5 autocomplete attributes, increasing manual mobile entry by 68%', evidence: ['EVD-CRO-007'] },
|
|
304
|
+
remediation: { preferred: 'Inject standard autocomplete attributes (autocomplete="email", autocomplete="name", autocomplete="tel")' },
|
|
305
|
+
verification: { detector_to_rerun: 'CRO-007', method: 'DOM inspection and AST patch verification' },
|
|
306
|
+
ice_score: 18.0,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
finding_id: 'F-CRO-DIST-013',
|
|
310
|
+
detector_id: 'CRO-013',
|
|
311
|
+
discipline: ['cro'],
|
|
312
|
+
classification: { severity: 'high', confidence: 'deterministic' },
|
|
313
|
+
subject: { identifier: 'https://example.test/checkout', url: 'https://example.test/checkout' },
|
|
314
|
+
observation: { summary: 'Dedicated checkout funnel contains 14 external header navigation links creating distraction leaks', evidence: ['EVD-CRO-013'] },
|
|
315
|
+
remediation: { preferred: 'Deploy enclosed distraction-free checkout layout stripping non-essential navigation' },
|
|
316
|
+
verification: { detector_to_rerun: 'CRO-013', method: 'Navigation link count verification' },
|
|
317
|
+
ice_score: 12.0,
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
finding_id: 'F-ANS-PRIC-001',
|
|
321
|
+
detector_id: 'ANS-001',
|
|
322
|
+
discipline: ['aeo'],
|
|
323
|
+
classification: { severity: 'high', confidence: 'deterministic' },
|
|
324
|
+
subject: { identifier: 'https://example.test/saas/pricing.html', url: 'https://example.test/saas/pricing.html' },
|
|
325
|
+
observation: { summary: 'Commercial pricing questions lack direct-extract definition passages under 75 words', evidence: ['EVD-ANS-001'] },
|
|
326
|
+
remediation: { preferred: 'Restructure FAQ headings with immediate concise copular answer passages' },
|
|
327
|
+
verification: { detector_to_rerun: 'ANS-001', method: 'Passage length and question-answer extraction test' },
|
|
328
|
+
ice_score: 11.0,
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
finding_id: 'F-SCHEMA-FAQ-001',
|
|
332
|
+
detector_id: 'SCHEMA-001',
|
|
333
|
+
discipline: ['schema'],
|
|
334
|
+
classification: { severity: 'medium', confidence: 'deterministic' },
|
|
335
|
+
subject: { identifier: 'https://example.test/pricing', url: 'https://example.test/pricing' },
|
|
336
|
+
observation: { summary: 'Commercial FAQ content lacks FAQPage JSON-LD schema markup', evidence: ['EVD-SCH-001'] },
|
|
337
|
+
remediation: { preferred: 'Deploy validated Schema.org FAQPage structured data' },
|
|
338
|
+
verification: { detector_to_rerun: 'SCHEMA-001', method: 'JSON-LD schema validation gate' },
|
|
339
|
+
ice_score: 9.5,
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
finding_id: 'F-EXP-GEO-999',
|
|
343
|
+
detector_id: 'EXP-GEO-999',
|
|
344
|
+
discipline: ['geo'],
|
|
345
|
+
classification: { severity: 'low', confidence: 'experimental', finding_type: 'experimental' },
|
|
346
|
+
subject: { identifier: 'https://unrelated-blog.test/post-1', url: 'https://unrelated-blog.test/post-1' },
|
|
347
|
+
observation: { summary: 'Speculative model hallucination on third-party forum', evidence: [] },
|
|
348
|
+
remediation: { preferred: 'Consult external legal counsel regarding public forum sentiment' },
|
|
349
|
+
ice_score: 2.0,
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
}
|
|
8
353
|
|
|
9
354
|
/**
|
|
10
355
|
* Generate an Enterprise-Grade Statement of Work (SOW) from SEO, AEO, GEO, SERP, and CRO findings.
|
|
11
|
-
* Forces strict traceability: Evidence -> Obligation -> Acceptance Test.
|
|
356
|
+
* Forces strict downward traceability: Evidence -> Obligation -> Acceptance Test.
|
|
12
357
|
*/
|
|
13
358
|
export async function generateSow(root, {
|
|
14
359
|
target,
|
|
15
360
|
baseUrl,
|
|
16
361
|
refDate,
|
|
17
362
|
runId,
|
|
363
|
+
live = false,
|
|
364
|
+
sample = false,
|
|
365
|
+
demo = false,
|
|
366
|
+
draft = false,
|
|
367
|
+
findings: inputFindings = null,
|
|
18
368
|
client = 'Acme Corporation',
|
|
19
369
|
clientContact = 'client-procurement@acme.test',
|
|
20
370
|
supplier = 'Nebula Components & Citable Practice',
|
|
21
371
|
supplierContact = 'advisory@nebulacomponents.test',
|
|
22
372
|
budget = 45000,
|
|
373
|
+
budgetMinor = null,
|
|
23
374
|
termDays = 90,
|
|
24
375
|
inScopeProperties = [],
|
|
376
|
+
scopeMode = 'HOST_AND_SUBDOMAINS',
|
|
25
377
|
minIceScore = 8.0,
|
|
378
|
+
allowedDisciplines = ['seo', 'aeo', 'geo', 'technical', 'schema', 'entity', 'cro'],
|
|
379
|
+
allowExperimental = false,
|
|
380
|
+
excludedDetectors = [],
|
|
381
|
+
roleMapping = null,
|
|
26
382
|
sowId = null,
|
|
27
383
|
} = {}) {
|
|
28
384
|
const generatedAt = nowIso();
|
|
29
385
|
const sowIdentifier = sowId || `SOW-${Date.now().toString(36).toUpperCase()}`;
|
|
386
|
+
const isSample = Boolean(sample || demo);
|
|
387
|
+
const isDraft = Boolean(draft);
|
|
388
|
+
const generationMode = isSample ? 'NON_CONTRACTUAL_SAMPLE' : (isDraft ? 'DRAFT' : 'CONTRACTUAL');
|
|
30
389
|
|
|
31
390
|
let findings = [];
|
|
32
|
-
let
|
|
391
|
+
let sourceProvenance = {
|
|
392
|
+
source_type: null,
|
|
393
|
+
source_identifier: null,
|
|
394
|
+
findings_count: 0,
|
|
395
|
+
integrity_hash: null,
|
|
396
|
+
};
|
|
33
397
|
|
|
34
|
-
//
|
|
35
|
-
if (
|
|
398
|
+
// 1. Precedence: Direct findings supplied programmatically
|
|
399
|
+
if (Array.isArray(inputFindings)) {
|
|
400
|
+
findings = inputFindings;
|
|
401
|
+
sourceProvenance.source_type = 'DIRECT_FINDINGS';
|
|
402
|
+
sourceProvenance.source_identifier = 'in_memory';
|
|
403
|
+
}
|
|
404
|
+
// 2. Precedence: Explicit run requested via --run <id>
|
|
405
|
+
else if (runId) {
|
|
406
|
+
const runsDir = path.join(root, '.citable', 'runs');
|
|
407
|
+
const runPath = path.join(runsDir, runId);
|
|
408
|
+
if (!fs.existsSync(runPath)) {
|
|
409
|
+
throw new RunNotFoundError(runId);
|
|
410
|
+
}
|
|
411
|
+
const findPath = path.join(runPath, 'findings.json');
|
|
412
|
+
if (!fs.existsSync(findPath)) {
|
|
413
|
+
throw new FindingsMissingError(runId, findPath);
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
findings = readJson(findPath);
|
|
417
|
+
if (!Array.isArray(findings)) throw new Error('findings.json root must be an array');
|
|
418
|
+
sourceProvenance.source_type = 'HISTORICAL_RUN';
|
|
419
|
+
sourceProvenance.source_identifier = runId;
|
|
420
|
+
sourceProvenance.integrity_hash = sha256(fs.readFileSync(findPath));
|
|
421
|
+
} catch (err) {
|
|
422
|
+
throw new FindingsInvalidError(runId, err.message);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// 3. Precedence: Live target inspection requested via --live or target
|
|
426
|
+
else if (live || target) {
|
|
427
|
+
if (!target) {
|
|
428
|
+
throw new SowError('Target is required for live inspection (e.g. --target <url|dir>)', 'TARGET_REQUIRED');
|
|
429
|
+
}
|
|
36
430
|
try {
|
|
37
431
|
const ctx = await buildContext(root, { target, baseUrl, refDate });
|
|
38
|
-
if (ctx?.site) {
|
|
39
|
-
|
|
40
|
-
const detectors = selectDetectors({ scopes: ['technical', 'seo', 'aeo', 'geo', 'schema', 'entity'] });
|
|
41
|
-
const res = runDetectors(detectors, ctx);
|
|
42
|
-
findings = res.findings;
|
|
432
|
+
if (!ctx?.site) {
|
|
433
|
+
throw new Error(`Target ${target} did not produce a valid site context`);
|
|
43
434
|
}
|
|
44
|
-
|
|
45
|
-
|
|
435
|
+
indexTargets(ctx);
|
|
436
|
+
const detectors = selectDetectors({ scopes: ['technical', 'seo', 'aeo', 'geo', 'schema', 'entity', 'cro'] });
|
|
437
|
+
const res = runDetectors(detectors, ctx);
|
|
438
|
+
findings = res.findings || [];
|
|
439
|
+
sourceProvenance.source_type = 'LIVE_INSPECTION';
|
|
440
|
+
sourceProvenance.source_identifier = target;
|
|
441
|
+
} catch (err) {
|
|
442
|
+
throw new LiveInspectionFailedError(target, err.message);
|
|
46
443
|
}
|
|
47
444
|
}
|
|
445
|
+
// 4. Precedence: Automatically load latest recorded run from .citable/runs if present
|
|
446
|
+
// Invariant: latest run = max(canonical run timestamp), tie = deterministic secondary key
|
|
447
|
+
else {
|
|
448
|
+
const runsDir = path.join(root, '.citable', 'runs');
|
|
449
|
+
if (fs.existsSync(runsDir)) {
|
|
450
|
+
const candidates = fs.readdirSync(runsDir)
|
|
451
|
+
.filter((d) => !d.startsWith('.') && fs.existsSync(path.join(runsDir, d, 'findings.json')));
|
|
452
|
+
const sortedCandidates = sortRunCandidatesChronologically(runsDir, candidates);
|
|
48
453
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
454
|
+
if (sortedCandidates.length > 0) {
|
|
455
|
+
const latestRunId = sortedCandidates[0];
|
|
456
|
+
const findPath = path.join(runsDir, latestRunId, 'findings.json');
|
|
457
|
+
try {
|
|
458
|
+
findings = readJson(findPath);
|
|
459
|
+
if (Array.isArray(findings)) {
|
|
460
|
+
sourceProvenance.source_type = 'LATEST_RECORDED_RUN';
|
|
461
|
+
sourceProvenance.source_identifier = latestRunId;
|
|
462
|
+
sourceProvenance.integrity_hash = sha256(fs.readFileSync(findPath));
|
|
463
|
+
}
|
|
464
|
+
} catch {}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
55
467
|
}
|
|
56
468
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
469
|
+
// 5. If no findings were discovered:
|
|
470
|
+
if (!Array.isArray(findings) || findings.length === 0) {
|
|
471
|
+
if (isSample) {
|
|
472
|
+
findings = getSampleBaselineFindings();
|
|
473
|
+
sourceProvenance.source_type = 'SAMPLE_BASELINE';
|
|
474
|
+
sourceProvenance.source_identifier = 'citable://samples/enterprise-baseline';
|
|
475
|
+
} else {
|
|
476
|
+
const srcDesc = target ? `live target "${target}"` : (runId ? `run "${runId}"` : 'local workspace');
|
|
477
|
+
throw new NoFindingsError(srcDesc);
|
|
62
478
|
}
|
|
63
479
|
}
|
|
64
480
|
|
|
65
|
-
|
|
66
|
-
if (findings.length === 0) {
|
|
67
|
-
findings = [
|
|
68
|
-
{
|
|
69
|
-
detector_id: 'TECH-001',
|
|
70
|
-
discipline: ['technical'],
|
|
71
|
-
classification: { severity: 'critical', confidence: 'deterministic' },
|
|
72
|
-
subject: { identifier: 'https://example.test/', url: 'https://example.test/' },
|
|
73
|
-
observation: { summary: 'Render-blocking JavaScript bundle degrades Mobile LCP to 4.2s', evidence: ['EVD-CWV-001'] },
|
|
74
|
-
remediation: { preferred: 'Implement asynchronous resource loading and preconnect headers' },
|
|
75
|
-
verification: { detector_to_rerun: 'TECH-001', method: 'Static CWV inspection and HTTP header probe' },
|
|
76
|
-
ice_score: 14.5,
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
detector_id: 'CRO-007',
|
|
80
|
-
discipline: ['cro'],
|
|
81
|
-
classification: { severity: 'high', confidence: 'deterministic' },
|
|
82
|
-
subject: { identifier: 'https://example.test/checkout', url: 'https://example.test/checkout' },
|
|
83
|
-
observation: { summary: 'Checkout form inputs lack HTML5 autocomplete attributes, increasing manual mobile entry by 68%', evidence: ['EVD-CRO-007'] },
|
|
84
|
-
remediation: { preferred: 'Inject standard autocomplete attributes (autocomplete="email", autocomplete="name", autocomplete="tel")' },
|
|
85
|
-
verification: { detector_to_rerun: 'CRO-007', method: 'DOM inspection and AST patch verification' },
|
|
86
|
-
ice_score: 18.0,
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
detector_id: 'CRO-013',
|
|
90
|
-
discipline: ['cro'],
|
|
91
|
-
classification: { severity: 'high', confidence: 'deterministic' },
|
|
92
|
-
subject: { identifier: 'https://example.test/checkout', url: 'https://example.test/checkout' },
|
|
93
|
-
observation: { summary: 'Dedicated checkout funnel contains 14 external header navigation links creating distraction leaks', evidence: ['EVD-CRO-013'] },
|
|
94
|
-
remediation: { preferred: 'Deploy enclosed distraction-free checkout layout stripping non-essential navigation' },
|
|
95
|
-
verification: { detector_to_rerun: 'CRO-013', method: 'Navigation link count verification' },
|
|
96
|
-
ice_score: 12.0,
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
detector_id: 'ANS-001',
|
|
100
|
-
discipline: ['aeo'],
|
|
101
|
-
classification: { severity: 'high', confidence: 'deterministic' },
|
|
102
|
-
subject: { identifier: 'https://example.test/saas/pricing.html', url: 'https://example.test/saas/pricing.html' },
|
|
103
|
-
observation: { summary: 'Commercial pricing questions lack direct-extract definition passages under 75 words', evidence: ['EVD-ANS-001'] },
|
|
104
|
-
remediation: { preferred: 'Restructure FAQ headings with immediate concise copular answer passages' },
|
|
105
|
-
verification: { detector_to_rerun: 'ANS-001', method: 'Passage length and question-answer extraction test' },
|
|
106
|
-
ice_score: 11.0,
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
detector_id: 'SCHEMA-001',
|
|
110
|
-
discipline: ['schema'],
|
|
111
|
-
classification: { severity: 'medium', confidence: 'deterministic' },
|
|
112
|
-
subject: { identifier: 'https://example.test/pricing', url: 'https://example.test/pricing' },
|
|
113
|
-
observation: { summary: 'Commercial FAQ content lacks FAQPage JSON-LD schema markup', evidence: ['EVD-SCH-001'] },
|
|
114
|
-
remediation: { preferred: 'Deploy validated Schema.org FAQPage structured data' },
|
|
115
|
-
verification: { detector_to_rerun: 'SCHEMA-001', method: 'JSON-LD schema validation gate' },
|
|
116
|
-
ice_score: 9.5,
|
|
117
|
-
},
|
|
118
|
-
// Inadmissible exploratory finding (for demonstrating the gate)
|
|
119
|
-
{
|
|
120
|
-
detector_id: 'EXP-GEO-999',
|
|
121
|
-
discipline: ['geo'],
|
|
122
|
-
classification: { severity: 'low', confidence: 'experimental', finding_type: 'experimental' },
|
|
123
|
-
subject: { identifier: 'https://unrelated-blog.test/post-1', url: 'https://unrelated-blog.test/post-1' },
|
|
124
|
-
observation: { summary: 'Speculative model hallucination on third-party forum', evidence: [] },
|
|
125
|
-
remediation: { preferred: 'Consult external legal counsel regarding public forum sentiment' },
|
|
126
|
-
ice_score: 2.0,
|
|
127
|
-
},
|
|
128
|
-
];
|
|
129
|
-
}
|
|
481
|
+
sourceProvenance.findings_count = findings.length;
|
|
130
482
|
|
|
131
483
|
const effectiveScopeProps = inScopeProperties.length > 0
|
|
132
484
|
? inScopeProperties
|
|
133
|
-
: [baseUrl
|
|
485
|
+
: (baseUrl ? [baseUrl] : (isSample ? ['https://example.test'] : []));
|
|
134
486
|
|
|
135
487
|
// -------------------------------------------------------------
|
|
136
|
-
// THE ADMISSIBILITY GATE (Filter findings
|
|
488
|
+
// THE ADMISSIBILITY GATE (Filter findings into contractual scope)
|
|
137
489
|
// -------------------------------------------------------------
|
|
138
490
|
const gateResult = evaluateScopeAdmissibility(findings, {
|
|
139
491
|
inScopeProperties: effectiveScopeProps,
|
|
492
|
+
scopeMode,
|
|
140
493
|
minIceScore,
|
|
494
|
+
allowedDisciplines,
|
|
495
|
+
allowExperimental,
|
|
496
|
+
excludedDetectors,
|
|
497
|
+
roleMapping,
|
|
141
498
|
});
|
|
142
499
|
|
|
143
500
|
const admitted = gateResult.admitted_requirements;
|
|
501
|
+
if (admitted.length === 0) {
|
|
502
|
+
throw new NoAdmissibleRequirementsError(findings.length, gateResult.refusal_log.length);
|
|
503
|
+
}
|
|
144
504
|
|
|
145
505
|
// Group admitted requirements into Work Packages
|
|
146
506
|
const wpMap = new Map();
|
|
@@ -162,20 +522,24 @@ export async function generateSow(root, {
|
|
|
162
522
|
// Mapping Finding -> Recommendation -> SOW Requirement -> Deliverable -> Acceptance Test -> Owner -> Evidence
|
|
163
523
|
const traceabilityMatrix = admitted.map((item) => ({
|
|
164
524
|
finding_id: item.finding_id,
|
|
525
|
+
detector_id: item.detector_id,
|
|
165
526
|
recommendation: item.recommendation,
|
|
166
527
|
sow_requirement_id: item.sow_requirement_id,
|
|
167
528
|
work_package_id: item.work_package_id,
|
|
168
529
|
deliverable_id: item.deliverable_id,
|
|
169
530
|
acceptance_test_id: item.acceptance_test_id,
|
|
170
531
|
owner: item.owner,
|
|
532
|
+
owner_source: item.owner_source,
|
|
533
|
+
owner_mapping_version: item.owner_mapping_version,
|
|
171
534
|
evidence_id: item.evidence_id,
|
|
535
|
+
evidence_ids: item.evidence_ids,
|
|
172
536
|
severity: item.severity,
|
|
173
537
|
target_subject: item.subject,
|
|
174
538
|
acceptance_criteria: item.acceptance_test,
|
|
175
539
|
}));
|
|
176
540
|
|
|
177
541
|
// Deliverables definitions
|
|
178
|
-
const deliverables = workPackages.map((wp
|
|
542
|
+
const deliverables = workPackages.map((wp) => ({
|
|
179
543
|
deliverable_id: wp.deliverable_id,
|
|
180
544
|
work_package_id: wp.work_package_id,
|
|
181
545
|
title: `${wp.name} Implementation & Verification Package`,
|
|
@@ -195,27 +559,41 @@ export async function generateSow(root, {
|
|
|
195
559
|
evidence_required: `Deterministic before-and-after observation JSON matching ${item.evidence_id}`,
|
|
196
560
|
}));
|
|
197
561
|
|
|
198
|
-
// Commercial structure
|
|
199
|
-
const
|
|
562
|
+
// Commercial structure with deterministic integer minor-unit arithmetic
|
|
563
|
+
const { feeMinor, feeUsd, currency, minorUnitExponent } = parseCommercialBudget({ budget, budgetMinor });
|
|
564
|
+
const milestoneFeesMinor = allocateMilestoneFees(feeMinor, workPackages.length);
|
|
565
|
+
const milestoneFeesUsd = milestoneFeesMinor.map((m) => Number((m / 100).toFixed(2)));
|
|
200
566
|
const commercialMilestones = workPackages.map((wp, idx) => ({
|
|
201
567
|
milestone_id: `MILESTONE-0${idx + 1}`,
|
|
202
568
|
work_package_id: wp.work_package_id,
|
|
203
569
|
name: wp.name,
|
|
204
|
-
|
|
570
|
+
fee_minor: milestoneFeesMinor[idx],
|
|
571
|
+
fee_usd: milestoneFeesUsd[idx],
|
|
205
572
|
billing_trigger: `Successful customer acceptance sign-off of Deliverable ${wp.deliverable_id}`,
|
|
206
573
|
target_delivery_week: (idx + 1) * 3,
|
|
207
574
|
}));
|
|
208
575
|
|
|
576
|
+
const sowTitle = isSample
|
|
577
|
+
? 'Statement of Work: Enterprise Search & Conversion Intelligence Engineering [DEMONSTRATION SAMPLE]'
|
|
578
|
+
: (isDraft
|
|
579
|
+
? 'Statement of Work: Enterprise Search & Conversion Intelligence Engineering [DRAFT]'
|
|
580
|
+
: 'Statement of Work: Enterprise Search & Conversion Intelligence Engineering');
|
|
581
|
+
|
|
209
582
|
const sow = {
|
|
210
583
|
$schema: 'citable://schemas/sow.schema.json',
|
|
211
584
|
sow_id: sowIdentifier,
|
|
212
585
|
version: '1.0.0',
|
|
213
|
-
|
|
586
|
+
generation_mode: generationMode,
|
|
587
|
+
title: sowTitle,
|
|
588
|
+
synthetic_evidence: isSample,
|
|
214
589
|
client: { name: client, contact: clientContact },
|
|
215
590
|
supplier: { name: supplier, contact: supplierContact },
|
|
216
591
|
effective_date: generatedAt.split('T')[0],
|
|
217
592
|
term_days: termDays,
|
|
218
|
-
|
|
593
|
+
currency,
|
|
594
|
+
currency_minor_unit_exponent: minorUnitExponent,
|
|
595
|
+
commercial_total_fee_minor: feeMinor,
|
|
596
|
+
commercial_total_fee_usd: feeUsd,
|
|
219
597
|
|
|
220
598
|
// Pillar 1: Executive Scope Statement
|
|
221
599
|
executive_scope: {
|
|
@@ -373,7 +751,9 @@ export async function generateSow(root, {
|
|
|
373
751
|
|
|
374
752
|
// Pillar 20: Commercial Structure
|
|
375
753
|
commercial_terms: {
|
|
376
|
-
|
|
754
|
+
total_fixed_fee_minor: feeMinor,
|
|
755
|
+
total_fixed_fee_usd: feeUsd,
|
|
756
|
+
currency,
|
|
377
757
|
payment_terms: 'Net 30 upon verified deliverable acceptance',
|
|
378
758
|
milestones: commercialMilestones,
|
|
379
759
|
},
|
|
@@ -413,8 +793,22 @@ export async function generateSow(root, {
|
|
|
413
793
|
|
|
414
794
|
// Pillar 25: The Final Traceability Matrix
|
|
415
795
|
traceability_matrix: traceabilityMatrix,
|
|
796
|
+
|
|
797
|
+
// Machine-readable Provenance Envelope
|
|
798
|
+
generation_provenance: {
|
|
799
|
+
generated_at: generatedAt,
|
|
800
|
+
generator_version: '1.18.1',
|
|
801
|
+
generation_mode: generationMode,
|
|
802
|
+
source_type: sourceProvenance.source_type || 'UNKNOWN',
|
|
803
|
+
source_identifier: sourceProvenance.source_identifier || null,
|
|
804
|
+
source_findings_count: sourceProvenance.findings_count,
|
|
805
|
+
findings_integrity_hash: sourceProvenance.integrity_hash || null,
|
|
806
|
+
synthetic_evidence: isSample,
|
|
807
|
+
},
|
|
416
808
|
};
|
|
417
809
|
|
|
810
|
+
validateSowInvariants(sow);
|
|
811
|
+
|
|
418
812
|
return sow;
|
|
419
813
|
}
|
|
420
814
|
|
|
@@ -422,16 +816,23 @@ export async function generateSow(root, {
|
|
|
422
816
|
* Render Statement of Work as GitHub-flavored Markdown
|
|
423
817
|
*/
|
|
424
818
|
export function renderSowMarkdown(sow) {
|
|
819
|
+
const modeBanner = sow.generation_mode === 'NON_CONTRACTUAL_SAMPLE'
|
|
820
|
+
? '> ⚠️ **NON-CONTRACTUAL DEMONSTRATION SAMPLE**: This document was generated with synthetic audit baseline findings for evaluation and demonstration purposes. It does NOT represent a binding contractual obligation or live audit evidence.'
|
|
821
|
+
: (sow.generation_mode === 'DRAFT'
|
|
822
|
+
? '> 📝 **DRAFT STATEMENT OF WORK**: This preliminary draft contains unfinalized engagement terms and requirements. Final executive approval required prior to signature.'
|
|
823
|
+
: '> **Contractual Principle**: This Statement of Work forces strict downward traceability from documented audit evidence to contractual obligation, and from obligation to verifiable acceptance. Findings are admitted strictly through a formal Admissibility Gate. In adherence to Citable governance principles, **no search rankings, AI citations, or conversion revenues are guaranteed**; fees are tied exclusively to objective deliverable acceptance.');
|
|
824
|
+
|
|
425
825
|
const lines = [
|
|
426
826
|
`# ${sow.title}`,
|
|
427
827
|
`====================================================================`,
|
|
428
|
-
`- **SOW ID**: \`${sow.sow_id}\` | **Version**: \`${sow.version}\``,
|
|
828
|
+
`- **SOW ID**: \`${sow.sow_id}\` | **Version**: \`${sow.version}\` | **Mode**: \`${sow.generation_mode}\``,
|
|
429
829
|
`- **Client**: **${sow.client.name}** (${sow.client.contact})`,
|
|
430
830
|
`- **Supplier**: **${sow.supplier.name}** (${sow.supplier.contact})`,
|
|
431
831
|
`- **Effective Date**: \`${sow.effective_date}\` | **Term**: \`${sow.term_days} calendar days\``,
|
|
432
|
-
`- **Commercial Total**: **$${sow.commercial_total_fee_usd.toLocaleString()} USD**`,
|
|
832
|
+
`- **Commercial Total**: **$${sow.commercial_total_fee_usd.toLocaleString('en-US', { minimumFractionDigits: (sow.commercial_total_fee_minor % 100 === 0) ? 0 : 2, maximumFractionDigits: 2 })} USD**`,
|
|
833
|
+
`- **Audit Source**: \`${sow.generation_provenance.source_type}\` (${sow.generation_provenance.source_identifier || 'N/A'}) | **Generator**: \`v${sow.generation_provenance.generator_version}\``,
|
|
433
834
|
``,
|
|
434
|
-
|
|
835
|
+
modeBanner,
|
|
435
836
|
``,
|
|
436
837
|
`---`,
|
|
437
838
|
`## 1. Executive Scope Statement`,
|
|
@@ -505,7 +906,7 @@ export function renderSowMarkdown(sow) {
|
|
|
505
906
|
`## 12. Delivery Sequencing & Milestone Schedule`,
|
|
506
907
|
`| Milestone | Work Package | Target Week | Fee (USD) | Acceptance Trigger |`,
|
|
507
908
|
`| :--- | :--- | :--- | :--- | :--- |`,
|
|
508
|
-
...sow.delivery_schedule.milestones.map((m) => `| **${m.milestone_id}** | \`${m.work_package_id}\` | Week ${m.target_delivery_week} | **$${m.fee_usd.toLocaleString()}** | ${m.billing_trigger} |`),
|
|
909
|
+
...sow.delivery_schedule.milestones.map((m) => `| **${m.milestone_id}** | \`${m.work_package_id}\` | Week ${m.target_delivery_week} | **$${m.fee_usd.toLocaleString('en-US', { minimumFractionDigits: (m.fee_minor % 100 === 0) ? 0 : 2, maximumFractionDigits: 2 })}** | ${m.billing_trigger} |`),
|
|
509
910
|
``,
|
|
510
911
|
`## 13. Scope Change-Control Process`,
|
|
511
912
|
`- **Procedure**: ${sow.change_control_process.procedure}`,
|
|
@@ -537,7 +938,7 @@ export function renderSowMarkdown(sow) {
|
|
|
537
938
|
`- **Decision Records**: ${sow.governance_model.decision_logging}`,
|
|
538
939
|
``,
|
|
539
940
|
`## 20. Commercial Terms & Payment Schedule`,
|
|
540
|
-
`- **Total Contract Value**: **$${sow.commercial_terms.total_fixed_fee_usd.toLocaleString()} USD**`,
|
|
941
|
+
`- **Total Contract Value**: **$${sow.commercial_terms.total_fixed_fee_usd.toLocaleString('en-US', { minimumFractionDigits: (sow.commercial_terms.total_fixed_fee_minor % 100 === 0) ? 0 : 2, maximumFractionDigits: 2 })} USD**`,
|
|
541
942
|
`- **Terms**: ${sow.commercial_terms.payment_terms}`,
|
|
542
943
|
``,
|
|
543
944
|
`## 21. Explicit Out-of-Scope Declarations`,
|
|
@@ -563,7 +964,7 @@ export function renderSowMarkdown(sow) {
|
|
|
563
964
|
``,
|
|
564
965
|
`| Finding ID | Recommendation | SOW Req ID | Deliverable ID | Acceptance Test ID | Responsible Owner | Source Evidence |`,
|
|
565
966
|
`| :--- | :--- | :--- | :--- | :--- | :--- | :--- |`,
|
|
566
|
-
...sow.traceability_matrix.map((t) => `| **\`${t.finding_id}\`** | ${t.recommendation.slice(0, 45)}... | **\`${t.sow_requirement_id}\`** | \`${t.deliverable_id}\` | \`${t.acceptance_test_id}\` | ${t.owner} | \`${t.evidence_id}\` |`),
|
|
967
|
+
...sow.traceability_matrix.map((t) => `| **\`${t.finding_id}\`** | ${t.recommendation.slice(0, 45)}... | **\`${t.sow_requirement_id}\`** | \`${t.deliverable_id}\` | \`${t.acceptance_test_id}\` | ${t.owner} | \`${t.evidence_ids?.join(', ') || t.evidence_id}\` |`),
|
|
567
968
|
``,
|
|
568
969
|
`---`,
|
|
569
970
|
`### Contract Execution & Authorization`,
|
|
@@ -583,6 +984,15 @@ export function renderSowMarkdown(sow) {
|
|
|
583
984
|
* Render Statement of Work as Standalone Enterprise HTML
|
|
584
985
|
*/
|
|
585
986
|
export function renderSowHtml(sow) {
|
|
987
|
+
const isSample = sow.generation_mode === 'NON_CONTRACTUAL_SAMPLE';
|
|
988
|
+
const isDraft = sow.generation_mode === 'DRAFT';
|
|
989
|
+
const badgeColor = isSample ? 'var(--warning)' : (isDraft ? 'var(--accent)' : 'var(--success)');
|
|
990
|
+
const modeNotice = isSample
|
|
991
|
+
? '<div class="disclosure" style="border-left-color:var(--warning); background:rgba(210,153,34,0.15);"><strong style="color:var(--warning);">DEMONSTRATION SAMPLE NOTICE:</strong> This Statement of Work contains synthetic audit baseline findings for evaluation and demonstration purposes. It does NOT represent a binding contractual obligation or live audit evidence.</div>'
|
|
992
|
+
: (isDraft
|
|
993
|
+
? '<div class="disclosure"><strong style="color:var(--accent);">DRAFT ENGAGEMENT NOTICE:</strong> This document represents a preliminary draft with unfinalized commercial parameters. Final executive sign-off required prior to contract binding.</div>'
|
|
994
|
+
: '<div class="disclosure"><strong>Enterprise Statement of Work Governance Notice:</strong> This agreement binds supplier fees exclusively to verified deliverable acceptance and closed-loop test execution. In compliance with Citable governance standards, <strong>no search engine ranking, AI citation presence, or commercial conversion revenue outcomes are guaranteed</strong>.</div>');
|
|
995
|
+
|
|
586
996
|
return `<!DOCTYPE html>
|
|
587
997
|
<html lang="en">
|
|
588
998
|
<head>
|
|
@@ -594,7 +1004,7 @@ export function renderSowHtml(sow) {
|
|
|
594
1004
|
.container { max-width: 1180px; margin: 0 auto; }
|
|
595
1005
|
header { border-bottom: 2px solid var(--border); padding-bottom: 24px; margin-bottom: 32px; }
|
|
596
1006
|
h1 { font-size: 28px; margin: 0 0 10px 0; color: #fff; }
|
|
597
|
-
.sow-badge { background:
|
|
1007
|
+
.sow-badge { background: ${badgeColor}; color: #000; padding: 4px 10px; border-radius: 4px; font-size: 13px; font-weight: bold; }
|
|
598
1008
|
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 24px 0; }
|
|
599
1009
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 18px; }
|
|
600
1010
|
.card-val { font-size: 26px; font-weight: bold; color: #fff; margin-top: 4px; }
|
|
@@ -616,19 +1026,20 @@ export function renderSowHtml(sow) {
|
|
|
616
1026
|
<header>
|
|
617
1027
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
618
1028
|
<h1>${sow.title}</h1>
|
|
619
|
-
<
|
|
1029
|
+
<div>
|
|
1030
|
+
<span class="sow-badge">${sow.generation_mode}</span>
|
|
1031
|
+
<span class="sow-badge" style="background:#1f6feb; color:#fff; margin-left:8px;">${sow.sow_id}</span>
|
|
1032
|
+
</div>
|
|
620
1033
|
</div>
|
|
621
1034
|
<div style="color:#8b949e; font-size:14px; margin-top:8px;">
|
|
622
|
-
Client: <strong>${sow.client.name}</strong> | Supplier: <strong>${sow.supplier.name}</strong> | Term: <strong>${sow.term_days} Days</strong> | Effective: <strong>${sow.effective_date}</strong>
|
|
1035
|
+
Client: <strong>${sow.client.name}</strong> | Supplier: <strong>${sow.supplier.name}</strong> | Term: <strong>${sow.term_days} Days</strong> | Effective: <strong>${sow.effective_date}</strong> | Source: <strong>${sow.generation_provenance.source_type}</strong>
|
|
623
1036
|
</div>
|
|
624
1037
|
</header>
|
|
625
1038
|
|
|
626
|
-
|
|
627
|
-
<strong>Enterprise Statement of Work Governance Notice:</strong> This agreement binds supplier fees exclusively to verified deliverable acceptance and closed-loop test execution. In compliance with Citable governance standards, <strong>no search engine ranking, AI citation presence, or commercial conversion revenue outcomes are guaranteed</strong>.
|
|
628
|
-
</div>
|
|
1039
|
+
${modeNotice}
|
|
629
1040
|
|
|
630
1041
|
<div class="meta-grid">
|
|
631
|
-
<div class="card"><div>Total Contract Fee</div><div class="card-val">$${sow.commercial_total_fee_usd.toLocaleString()}</div></div>
|
|
1042
|
+
<div class="card"><div>Total Contract Fee</div><div class="card-val">$${sow.commercial_total_fee_usd.toLocaleString('en-US', { minimumFractionDigits: (sow.commercial_total_fee_minor % 100 === 0) ? 0 : 2, maximumFractionDigits: 2 })}</div></div>
|
|
632
1043
|
<div class="card"><div>Admitted SOW Requirements</div><div class="card-val">${sow.admissibility_gate.admitted_count}</div></div>
|
|
633
1044
|
<div class="card"><div>Refused / Excluded Scope</div><div class="card-val" style="color:var(--danger);">${sow.admissibility_gate.refused_count}</div></div>
|
|
634
1045
|
<div class="card"><div>Contracted Work Packages</div><div class="card-val">${sow.work_packages.length}</div></div>
|
|
@@ -672,7 +1083,7 @@ export function renderSowHtml(sow) {
|
|
|
672
1083
|
<td>${m.name}</td>
|
|
673
1084
|
<td><span class="code-ref">${m.work_package_id}</span></td>
|
|
674
1085
|
<td>${m.billing_trigger}</td>
|
|
675
|
-
<td style="font-weight:bold; color:var(--success);">$${m.fee_usd.toLocaleString()} USD</td>
|
|
1086
|
+
<td style="font-weight:bold; color:var(--success);">$${m.fee_usd.toLocaleString('en-US', { minimumFractionDigits: (m.fee_minor % 100 === 0) ? 0 : 2, maximumFractionDigits: 2 })} USD</td>
|
|
676
1087
|
</tr>
|
|
677
1088
|
`).join('')}
|
|
678
1089
|
</tbody>
|
|
@@ -693,7 +1104,7 @@ export function renderSowHtml(sow) {
|
|
|
693
1104
|
<td>${t.deliverable_id}</td>
|
|
694
1105
|
<td><code>${t.acceptance_test_id}</code></td>
|
|
695
1106
|
<td>${t.owner}</td>
|
|
696
|
-
<td><span class="code-ref">${t.evidence_id}</span></td>
|
|
1107
|
+
<td><span class="code-ref">${t.evidence_ids?.join(', ') || t.evidence_id}</span></td>
|
|
697
1108
|
</tr>
|
|
698
1109
|
`).join('')}
|
|
699
1110
|
</tbody>
|
|
@@ -727,10 +1138,15 @@ export function renderSowHtml(sow) {
|
|
|
727
1138
|
}
|
|
728
1139
|
|
|
729
1140
|
/**
|
|
730
|
-
* Export SOW deliverable to filesystem or string
|
|
1141
|
+
* Export SOW deliverable to filesystem or string with strict schema validation
|
|
731
1142
|
*/
|
|
732
1143
|
export async function exportSow(root, options = {}) {
|
|
733
1144
|
const sow = await generateSow(root, options);
|
|
1145
|
+
const validation = validateAgainst('sow.schema.json', sow);
|
|
1146
|
+
if (!validation.valid) {
|
|
1147
|
+
throw new SowError(`Generated SOW violates schemas/sow.schema.json: ${validation.errors.join('; ')}`, 'SCHEMA_VALIDATION_FAILED');
|
|
1148
|
+
}
|
|
1149
|
+
|
|
734
1150
|
const format = options.format || 'markdown';
|
|
735
1151
|
let content = '';
|
|
736
1152
|
|