@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/universal/.agents/skills/citable/SKILL.md +22 -1
  3. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  4. package/dist/universal/.agents/skills/citable/agents/citable-remediator.md +43 -0
  5. package/dist/universal/.agents/skills/citable/agents/citable-sow-architect.md +43 -0
  6. package/dist/universal/.agents/skills/citable/commands/README.md +13 -0
  7. package/dist/universal/.agents/skills/citable/commands/remediation.md +55 -0
  8. package/dist/universal/.agents/skills/citable/commands/reporting.md +28 -0
  9. package/dist/universal/.agents/skills/citable/commands/sow.md +95 -0
  10. package/dist/universal/.agents/skills/citable/manifest.json +12 -8
  11. package/dist/universal/.agents/skills/citable/schemas/sow.schema.json +342 -25
  12. package/dist/universal/.claude/agents/citable/citable-remediator.md +43 -0
  13. package/dist/universal/.claude/agents/citable/citable-sow-architect.md +43 -0
  14. package/dist/universal/.claude/agents/citable/manifest.json +6 -4
  15. package/dist/universal/.claude/skills/citable/SKILL.md +22 -1
  16. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  17. package/dist/universal/.claude/skills/citable/agents/citable-remediator.md +43 -0
  18. package/dist/universal/.claude/skills/citable/agents/citable-sow-architect.md +43 -0
  19. package/dist/universal/.claude/skills/citable/commands/README.md +13 -0
  20. package/dist/universal/.claude/skills/citable/commands/remediation.md +55 -0
  21. package/dist/universal/.claude/skills/citable/commands/reporting.md +28 -0
  22. package/dist/universal/.claude/skills/citable/commands/sow.md +95 -0
  23. package/dist/universal/.claude/skills/citable/manifest.json +16 -10
  24. package/dist/universal/.claude/skills/citable/schemas/sow.schema.json +342 -25
  25. package/dist/universal/.cursor/skills/citable/SKILL.md +22 -1
  26. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  27. package/dist/universal/.cursor/skills/citable/agents/citable-remediator.md +43 -0
  28. package/dist/universal/.cursor/skills/citable/agents/citable-sow-architect.md +43 -0
  29. package/dist/universal/.cursor/skills/citable/commands/README.md +13 -0
  30. package/dist/universal/.cursor/skills/citable/commands/remediation.md +55 -0
  31. package/dist/universal/.cursor/skills/citable/commands/reporting.md +28 -0
  32. package/dist/universal/.cursor/skills/citable/commands/sow.md +95 -0
  33. package/dist/universal/.cursor/skills/citable/manifest.json +12 -8
  34. package/dist/universal/.cursor/skills/citable/schemas/sow.schema.json +342 -25
  35. package/dist/universal/.gemini/skills/citable/SKILL.md +22 -1
  36. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  37. package/dist/universal/.gemini/skills/citable/agents/citable-remediator.md +43 -0
  38. package/dist/universal/.gemini/skills/citable/agents/citable-sow-architect.md +43 -0
  39. package/dist/universal/.gemini/skills/citable/commands/README.md +13 -0
  40. package/dist/universal/.gemini/skills/citable/commands/remediation.md +55 -0
  41. package/dist/universal/.gemini/skills/citable/commands/reporting.md +28 -0
  42. package/dist/universal/.gemini/skills/citable/commands/sow.md +95 -0
  43. package/dist/universal/.gemini/skills/citable/manifest.json +12 -8
  44. package/dist/universal/.gemini/skills/citable/schemas/sow.schema.json +342 -25
  45. package/dist/universal/.github/skills/citable/SKILL.md +22 -1
  46. package/dist/universal/.github/skills/citable/VERSION +1 -1
  47. package/dist/universal/.github/skills/citable/agents/citable-remediator.md +43 -0
  48. package/dist/universal/.github/skills/citable/agents/citable-sow-architect.md +43 -0
  49. package/dist/universal/.github/skills/citable/commands/README.md +13 -0
  50. package/dist/universal/.github/skills/citable/commands/remediation.md +55 -0
  51. package/dist/universal/.github/skills/citable/commands/reporting.md +28 -0
  52. package/dist/universal/.github/skills/citable/commands/sow.md +95 -0
  53. package/dist/universal/.github/skills/citable/manifest.json +12 -8
  54. package/dist/universal/.github/skills/citable/schemas/sow.schema.json +342 -25
  55. package/dist/universal/.kiro/skills/citable/SKILL.md +22 -1
  56. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  57. package/dist/universal/.kiro/skills/citable/agents/citable-remediator.md +43 -0
  58. package/dist/universal/.kiro/skills/citable/agents/citable-sow-architect.md +43 -0
  59. package/dist/universal/.kiro/skills/citable/commands/README.md +13 -0
  60. package/dist/universal/.kiro/skills/citable/commands/remediation.md +55 -0
  61. package/dist/universal/.kiro/skills/citable/commands/reporting.md +28 -0
  62. package/dist/universal/.kiro/skills/citable/commands/sow.md +95 -0
  63. package/dist/universal/.kiro/skills/citable/manifest.json +12 -8
  64. package/dist/universal/.kiro/skills/citable/schemas/sow.schema.json +342 -25
  65. package/dist/universal/.opencode/skills/citable/SKILL.md +22 -1
  66. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  67. package/dist/universal/.opencode/skills/citable/agents/citable-remediator.md +43 -0
  68. package/dist/universal/.opencode/skills/citable/agents/citable-sow-architect.md +43 -0
  69. package/dist/universal/.opencode/skills/citable/commands/README.md +13 -0
  70. package/dist/universal/.opencode/skills/citable/commands/remediation.md +55 -0
  71. package/dist/universal/.opencode/skills/citable/commands/reporting.md +28 -0
  72. package/dist/universal/.opencode/skills/citable/commands/sow.md +95 -0
  73. package/dist/universal/.opencode/skills/citable/manifest.json +12 -8
  74. package/dist/universal/.opencode/skills/citable/schemas/sow.schema.json +342 -25
  75. package/dist/universal/.pi/agent/skills/citable/SKILL.md +22 -1
  76. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  77. package/dist/universal/.pi/agent/skills/citable/agents/citable-remediator.md +43 -0
  78. package/dist/universal/.pi/agent/skills/citable/agents/citable-sow-architect.md +43 -0
  79. package/dist/universal/.pi/agent/skills/citable/commands/README.md +13 -0
  80. package/dist/universal/.pi/agent/skills/citable/commands/remediation.md +55 -0
  81. package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +28 -0
  82. package/dist/universal/.pi/agent/skills/citable/commands/sow.md +95 -0
  83. package/dist/universal/.pi/agent/skills/citable/manifest.json +12 -8
  84. package/dist/universal/.pi/agent/skills/citable/schemas/sow.schema.json +342 -25
  85. package/dist/universal/.qoder/skills/citable/SKILL.md +22 -1
  86. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  87. package/dist/universal/.qoder/skills/citable/agents/citable-remediator.md +43 -0
  88. package/dist/universal/.qoder/skills/citable/agents/citable-sow-architect.md +43 -0
  89. package/dist/universal/.qoder/skills/citable/commands/README.md +13 -0
  90. package/dist/universal/.qoder/skills/citable/commands/remediation.md +55 -0
  91. package/dist/universal/.qoder/skills/citable/commands/reporting.md +28 -0
  92. package/dist/universal/.qoder/skills/citable/commands/sow.md +95 -0
  93. package/dist/universal/.qoder/skills/citable/manifest.json +12 -8
  94. package/dist/universal/.qoder/skills/citable/schemas/sow.schema.json +342 -25
  95. package/dist/universal/.rovodev/skills/citable/SKILL.md +22 -1
  96. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  97. package/dist/universal/.rovodev/skills/citable/agents/citable-remediator.md +43 -0
  98. package/dist/universal/.rovodev/skills/citable/agents/citable-sow-architect.md +43 -0
  99. package/dist/universal/.rovodev/skills/citable/commands/README.md +13 -0
  100. package/dist/universal/.rovodev/skills/citable/commands/remediation.md +55 -0
  101. package/dist/universal/.rovodev/skills/citable/commands/reporting.md +28 -0
  102. package/dist/universal/.rovodev/skills/citable/commands/sow.md +95 -0
  103. package/dist/universal/.rovodev/skills/citable/manifest.json +12 -8
  104. package/dist/universal/.rovodev/skills/citable/schemas/sow.schema.json +342 -25
  105. package/dist/universal/.trae/skills/citable/SKILL.md +22 -1
  106. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  107. package/dist/universal/.trae/skills/citable/agents/citable-remediator.md +43 -0
  108. package/dist/universal/.trae/skills/citable/agents/citable-sow-architect.md +43 -0
  109. package/dist/universal/.trae/skills/citable/commands/README.md +13 -0
  110. package/dist/universal/.trae/skills/citable/commands/remediation.md +55 -0
  111. package/dist/universal/.trae/skills/citable/commands/reporting.md +28 -0
  112. package/dist/universal/.trae/skills/citable/commands/sow.md +95 -0
  113. package/dist/universal/.trae/skills/citable/manifest.json +12 -8
  114. package/dist/universal/.trae/skills/citable/schemas/sow.schema.json +342 -25
  115. package/dist/universal/.trae-cn/skills/citable/SKILL.md +22 -1
  116. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  117. package/dist/universal/.trae-cn/skills/citable/agents/citable-remediator.md +43 -0
  118. package/dist/universal/.trae-cn/skills/citable/agents/citable-sow-architect.md +43 -0
  119. package/dist/universal/.trae-cn/skills/citable/commands/README.md +13 -0
  120. package/dist/universal/.trae-cn/skills/citable/commands/remediation.md +55 -0
  121. package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +28 -0
  122. package/dist/universal/.trae-cn/skills/citable/commands/sow.md +95 -0
  123. package/dist/universal/.trae-cn/skills/citable/manifest.json +12 -8
  124. package/dist/universal/.trae-cn/skills/citable/schemas/sow.schema.json +342 -25
  125. package/dist/universal/manifest.json +150 -100
  126. package/package.json +1 -1
  127. package/schemas/sow.schema.json +342 -25
  128. package/skill/SKILL.md +22 -1
  129. package/skill/agents/citable-remediator.md +43 -0
  130. package/skill/agents/citable-sow-architect.md +43 -0
  131. package/skill/commands/README.md +13 -0
  132. package/skill/commands/remediation.md +55 -0
  133. package/skill/commands/reporting.md +28 -0
  134. package/skill/commands/sow.md +95 -0
  135. package/src/commands/sowCmd.js +17 -0
  136. package/src/sow/admissibilityGate.js +216 -46
  137. package/src/sow/generateSow.js +529 -113
@@ -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 sitePages = [];
391
+ let sourceProvenance = {
392
+ source_type: null,
393
+ source_identifier: null,
394
+ findings_count: 0,
395
+ integrity_hash: null,
396
+ };
33
397
 
34
- // Attempt building live or target context if target provided
35
- if (target) {
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
- sitePages = indexTargets(ctx);
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
- } catch (e) {
45
- // Non-blocking fallback
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
- // If runId provided or prior runs exist in .citable/runs, load recorded evidence
50
- const runsDir = path.join(root, '.citable', 'runs');
51
- let loadedRunId = runId;
52
- if (!loadedRunId && fs.existsSync(runsDir)) {
53
- const runs = fs.readdirSync(runsDir).filter((d) => !d.startsWith('.'));
54
- if (runs.length > 0) loadedRunId = runs[runs.length - 1];
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
- if (loadedRunId) {
58
- const runPath = path.join(runsDir, loadedRunId);
59
- const findPath = path.join(runPath, 'findings.json');
60
- if (fs.existsSync(findPath) && findings.length === 0) {
61
- try { findings = readJson(findPath); } catch {}
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
- // If no findings exist from live run, supply standard baseline audit findings for SOW generation
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 || 'https://example.test'];
485
+ : (baseUrl ? [baseUrl] : (isSample ? ['https://example.test'] : []));
134
486
 
135
487
  // -------------------------------------------------------------
136
- // THE ADMISSIBILITY GATE (Filter findings to contractual scope)
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, idx) => ({
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 feePerWp = Math.round(budget / (workPackages.length || 1));
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
- fee_usd: feePerWp,
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
- title: `Statement of Work: Enterprise Search & Conversion Intelligence Engineering`,
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
- commercial_total_fee_usd: budget,
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
- total_fixed_fee_usd: budget,
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
- `> **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.`,
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: #1f6feb; color: #fff; padding: 4px 10px; border-radius: 4px; font-size: 13px; font-weight: bold; }
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
- <span class="sow-badge">${sow.sow_id}</span>
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> &nbsp;|&nbsp; Supplier: <strong>${sow.supplier.name}</strong> &nbsp;|&nbsp; Term: <strong>${sow.term_days} Days</strong> &nbsp;|&nbsp; Effective: <strong>${sow.effective_date}</strong>
1035
+ Client: <strong>${sow.client.name}</strong> &nbsp;|&nbsp; Supplier: <strong>${sow.supplier.name}</strong> &nbsp;|&nbsp; Term: <strong>${sow.term_days} Days</strong> &nbsp;|&nbsp; Effective: <strong>${sow.effective_date}</strong> &nbsp;|&nbsp; Source: <strong>${sow.generation_provenance.source_type}</strong>
623
1036
  </div>
624
1037
  </header>
625
1038
 
626
- <div class="disclosure">
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