fraim 2.0.339 → 2.0.341
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/dist/registry/scripts/learning/completion-state.js +161 -0
- package/dist/src/cli/commands/cleanup-artifacts.js +2 -2
- package/dist/src/core/ai-mentor.js +1 -1
- package/dist/src/core/handoff-contracts.js +165 -37
- package/dist/src/core/proposal-set-contract.js +214 -0
- package/dist/src/core/run-recovery-state.js +9 -0
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +224 -20
- package/dist/src/local-mcp-server/learning-usage-analysis.js +35 -19
- package/dist/src/local-mcp-server/learning-usage-command.js +13 -14
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +59 -0
- package/dist/src/local-mcp-server/learning-usage-events.js +63 -57
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +235 -79
- package/dist/src/local-mcp-server/learning-usage-verify.js +13 -26
- package/dist/src/local-mcp-server/stdio-server.js +295 -112
- package/dist/src/local-mcp-server/usage-collector.js +5 -29
- package/dist/src/mcp/tool-schemas.js +2 -2
- package/dist/src/routes/analytics.js +10 -3
- package/dist/src/services/mcp-service.js +71 -11
- package/dist/src/services/usage-analytics-service.js +6 -3
- package/package.json +2 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.COMPLETION_END = exports.COMPLETION_START = void 0;
|
|
7
|
+
exports.validateProcessedSources = validateProcessedSources;
|
|
8
|
+
exports.decodeCompletion = decodeCompletion;
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
function validateProcessedSources(sources) {
|
|
11
|
+
if (!Array.isArray(sources) || new Set(sources.map(item => item.path)).size !== sources.length || sources.some(item => !item || !node_path_1.default.isAbsolute(item.path) || !node_path_1.default.isAbsolute(item.destination) || !['raw', 'retrospective'].includes(item.kind)
|
|
12
|
+
|| !/^[a-f0-9]{64}$/.test(item.sha256) || !/^[a-f0-9]{64}$/.test(item.resultHash) || !/^\d{4}-\d{2}-\d{2}$/.test(item.synthesizedAt)))
|
|
13
|
+
throw new Error('invalid-processed-sources');
|
|
14
|
+
}
|
|
15
|
+
exports.COMPLETION_START = '<!-- fraim-sleep-decisions:v1 -->';
|
|
16
|
+
exports.COMPLETION_END = '<!-- /fraim-sleep-decisions -->';
|
|
17
|
+
const text = (value) => typeof value === 'string' && !!value.trim();
|
|
18
|
+
const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
19
|
+
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
const keys = (value, allowed) => object(value) && Object.keys(value).every(key => allowed.includes(key));
|
|
21
|
+
const ownerValid = (value) => object(value) && ['Project', 'Manager', 'Org'].includes(value.level)
|
|
22
|
+
&& keys(value, ['level', 'path']) && text(value.path) && node_path_1.default.isAbsolute(value.path)
|
|
23
|
+
&& !/[\x00-\x1f]/.test(value.path) && value.path === (process.platform === 'win32' ? node_path_1.default.resolve(value.path).replace(/\\/g, '/').toLowerCase() : node_path_1.default.resolve(value.path));
|
|
24
|
+
const sameOwner = (a, b) => a.level === b.level && a.path === b.path;
|
|
25
|
+
function decodeCompletion(markdown, expected = {}) {
|
|
26
|
+
const fail = () => { throw new Error('invalid-completion-decisions'); };
|
|
27
|
+
if ((markdown.match(/<!--\s*fraim-sleep-decisions:/g) || []).length !== 1 || markdown.split(exports.COMPLETION_START).length !== 2 || markdown.split(exports.COMPLETION_END).length !== 2)
|
|
28
|
+
fail();
|
|
29
|
+
const start = markdown.indexOf(exports.COMPLETION_START) + exports.COMPLETION_START.length;
|
|
30
|
+
const end = markdown.indexOf(exports.COMPLETION_END);
|
|
31
|
+
if (end < start)
|
|
32
|
+
fail();
|
|
33
|
+
const block = markdown.slice(start, end).match(/^\s*```json\r?\n([\s\S]*?)\r?\n```\s*$/);
|
|
34
|
+
if (!block)
|
|
35
|
+
fail();
|
|
36
|
+
let state;
|
|
37
|
+
try {
|
|
38
|
+
state = JSON.parse(block[1]);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
fail();
|
|
42
|
+
}
|
|
43
|
+
if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
|
|
44
|
+
|| !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
|
|
45
|
+
fail();
|
|
46
|
+
if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
|
|
47
|
+
fail();
|
|
48
|
+
if (state.retrospective !== undefined && (!keys(state.retrospective, ['relativePath', 'sha256'])
|
|
49
|
+
|| state.retrospective.relativePath !== `retrospectives/${state.runId}-sleep-postmortem.md` || !hash(state.retrospective.sha256)))
|
|
50
|
+
fail();
|
|
51
|
+
if (state.processedSources !== undefined)
|
|
52
|
+
validateProcessedSources(state.processedSources);
|
|
53
|
+
const ids = new Set();
|
|
54
|
+
for (const item of state.decisions) {
|
|
55
|
+
if (!keys(item, ['actionId', 'revision', 'digest', 'owner', 'claimKey', 'evidenceFingerprint', 'sourceRefs', 'decision', 'publicationRef', 'target', 'dependencies']) || !text(item.actionId) || ids.has(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !ownerValid(item.owner)
|
|
56
|
+
|| !text(item.claimKey) || !text(item.evidenceFingerprint) || !Array.isArray(item.sourceRefs) || !item.sourceRefs.every(text) || (item.publicationRef !== undefined && !text(item.publicationRef)))
|
|
57
|
+
fail();
|
|
58
|
+
ids.add(item.actionId);
|
|
59
|
+
const decision = item.decision;
|
|
60
|
+
// Issue #1857 R6 and D2: `basis`/`predecessorId` say who reached the decision,
|
|
61
|
+
// `kind` says whether a rejection refused the claim or was superseded by a
|
|
62
|
+
// rule. All three are optional so evidence written before this still decodes,
|
|
63
|
+
// and each is constrained here on the same terms `RunRecord.decide` uses.
|
|
64
|
+
if (!keys(decision, ['action', 'revision', 'digest', 'reviewer', 'reason', 'revisitCondition', 'basis', 'predecessorId', 'kind', 'at']) || !['approve', 'reject'].includes(decision.action) || decision.revision !== item.revision || decision.digest !== item.digest
|
|
65
|
+
|| !text(decision.reviewer) || !text(decision.reason) || !text(decision.at) || !Number.isFinite(Date.parse(decision.at))
|
|
66
|
+
|| (decision.revisitCondition !== undefined && (decision.action !== 'reject' || !text(decision.revisitCondition)))
|
|
67
|
+
|| (decision.basis !== undefined && !['manager', 'inherited'].includes(decision.basis))
|
|
68
|
+
|| (decision.basis === 'inherited' && !text(decision.predecessorId))
|
|
69
|
+
|| (decision.predecessorId !== undefined && decision.basis !== 'inherited')
|
|
70
|
+
|| (decision.kind !== undefined && (decision.action !== 'reject' || !['refused', 'superseded'].includes(decision.kind))))
|
|
71
|
+
fail();
|
|
72
|
+
if ('content' in item || 'specification' in item || 'application' in item || 'preimage' in item)
|
|
73
|
+
fail();
|
|
74
|
+
if (item.target && (!keys(item.target, ['owner', 'relativePath', 'entryId', 'learningSubject']) || !ownerValid(item.target.owner) || !text(item.target.relativePath) || /^[\\/]|^[a-zA-Z]:/.test(item.target.relativePath) || item.target.relativePath.split('/').includes('..')))
|
|
75
|
+
fail();
|
|
76
|
+
if (item.dependencies && (!Array.isArray(item.dependencies) || item.dependencies.some((dependency) => !keys(dependency, ['actionId', 'outcome']) || !text(dependency.actionId) || !['applied', 'rejected'].includes(dependency.outcome))))
|
|
77
|
+
fail();
|
|
78
|
+
}
|
|
79
|
+
for (const source of state.sourceDispositions) {
|
|
80
|
+
if (!keys(source, ['sourceRef', 'sourceHash', 'outcome', 'actionId']) || !text(source.sourceRef) || !hash(source.sourceHash) || !['applied', 'rejected'].includes(source.outcome) || !ids.has(source.actionId))
|
|
81
|
+
fail();
|
|
82
|
+
}
|
|
83
|
+
const decisions = new Map(state.decisions.map((item) => [item.actionId, item]));
|
|
84
|
+
const outcome = (item) => item.decision.action === 'approve' ? 'applied' : 'rejected';
|
|
85
|
+
const dispositions = new Set();
|
|
86
|
+
for (const source of state.sourceDispositions) {
|
|
87
|
+
const action = decisions.get(source.actionId);
|
|
88
|
+
const key = JSON.stringify([source.actionId, source.sourceRef]);
|
|
89
|
+
if (dispositions.has(key) || !action.sourceRefs.includes(source.sourceRef) || source.outcome !== outcome(action))
|
|
90
|
+
fail();
|
|
91
|
+
dispositions.add(key);
|
|
92
|
+
}
|
|
93
|
+
for (const action of decisions.values()) {
|
|
94
|
+
if (action.target && !sameOwner(action.owner, action.target.owner))
|
|
95
|
+
fail();
|
|
96
|
+
if (new Set(action.sourceRefs).size !== action.sourceRefs.length || action.sourceRefs.some(ref => !dispositions.has(JSON.stringify([action.actionId, ref]))))
|
|
97
|
+
fail();
|
|
98
|
+
const dependencies = action.dependencies || [];
|
|
99
|
+
if (new Set(dependencies.map(dependency => dependency.actionId)).size !== dependencies.length)
|
|
100
|
+
fail();
|
|
101
|
+
for (const dependency of dependencies) {
|
|
102
|
+
const target = decisions.get(dependency.actionId);
|
|
103
|
+
if (!target || target.actionId === action.actionId || dependency.outcome !== outcome(target)
|
|
104
|
+
|| (action.decision.action === 'approve' && dependency.outcome !== 'applied'))
|
|
105
|
+
fail();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const checked = new Set();
|
|
109
|
+
const visiting = new Set();
|
|
110
|
+
const visit = (id) => {
|
|
111
|
+
if (visiting.has(id))
|
|
112
|
+
fail();
|
|
113
|
+
if (checked.has(id))
|
|
114
|
+
return;
|
|
115
|
+
visiting.add(id);
|
|
116
|
+
for (const dependency of decisions.get(id).dependencies || [])
|
|
117
|
+
visit(dependency.actionId);
|
|
118
|
+
visiting.delete(id);
|
|
119
|
+
checked.add(id);
|
|
120
|
+
};
|
|
121
|
+
for (const id of decisions.keys())
|
|
122
|
+
visit(id);
|
|
123
|
+
for (const item of state.cleanup) {
|
|
124
|
+
if (!keys(item, ['path', 'outcome', 'reason', 'sha256', 'location']) || !text(item.path) || !['pending', 'deleted', 'absent', 'retained'].includes(item.outcome) || (item.reason !== undefined && typeof item.reason !== 'string') || (item.sha256 !== undefined && !hash(item.sha256)))
|
|
125
|
+
fail();
|
|
126
|
+
if (item.location !== undefined) {
|
|
127
|
+
const location = item.location;
|
|
128
|
+
if (!keys(location, ['owner', 'relativePath']) || !ownerValid(location.owner)
|
|
129
|
+
|| !text(location.relativePath) || !/^learnings\/(?:review-queue|review-archive|review-evidence|archive)\//.test(location.relativePath)
|
|
130
|
+
|| /[\\:]/.test(location.relativePath) || location.relativePath.split('/').some((part) => !part || part === '.' || part === '..'))
|
|
131
|
+
fail();
|
|
132
|
+
}
|
|
133
|
+
if (state.finalizationState === 'complete' && item.outcome === 'pending')
|
|
134
|
+
fail();
|
|
135
|
+
}
|
|
136
|
+
if (state.reconciliations !== undefined) {
|
|
137
|
+
if (!Array.isArray(state.reconciliations))
|
|
138
|
+
fail();
|
|
139
|
+
const sources = new Set();
|
|
140
|
+
for (const item of state.reconciliations) {
|
|
141
|
+
if (!keys(item, ['sourceRef', 'sourceHash', 'owner', 'relativePath', 'actionIds', 'actionBindings', 'disposition', 'rationale', 'preservedDecisions', 'reviewer'])
|
|
142
|
+
|| !text(item.sourceRef) || sources.has(item.sourceRef) || !hash(item.sourceHash) || !ownerValid(item.owner)
|
|
143
|
+
|| !text(item.relativePath) || !/^learnings\/(?:review-queue|review-archive|review-evidence|archive)\//.test(item.relativePath)
|
|
144
|
+
|| /[\\:]/.test(item.relativePath) || item.relativePath.split('/').some((part) => !part || part === '.' || part === '..')
|
|
145
|
+
|| !['accounted', 'unrelated', 'raw-source'].includes(item.disposition) || !text(item.rationale) || !text(item.reviewer)
|
|
146
|
+
|| !Array.isArray(item.preservedDecisions) || !item.preservedDecisions.every(text)
|
|
147
|
+
|| !Array.isArray(item.actionIds) || new Set(item.actionIds).size !== item.actionIds.length
|
|
148
|
+
|| !Array.isArray(item.actionBindings) || item.actionBindings.length !== item.actionIds.length)
|
|
149
|
+
fail();
|
|
150
|
+
sources.add(item.sourceRef);
|
|
151
|
+
for (const [index, id] of item.actionIds.entries()) {
|
|
152
|
+
const decision = decisions.get(id);
|
|
153
|
+
const binding = item.actionBindings[index];
|
|
154
|
+
if (!decision || !keys(binding, ['actionId', 'revision', 'digest']) || binding.actionId !== id || binding.revision !== decision.revision || binding.digest !== decision.digest
|
|
155
|
+
|| !state.sourceDispositions.some((source) => source.actionId === id && source.sourceRef.replace(/\\/g, '/') === item.sourceRef.replace(/\\/g, '/') && source.sourceHash === item.sourceHash))
|
|
156
|
+
fail();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return state;
|
|
161
|
+
}
|
|
@@ -22,10 +22,10 @@ function runCleanupArtifactsCommand(options) {
|
|
|
22
22
|
}
|
|
23
23
|
exports.cleanupArtifactsCommand = new commander_1.Command('cleanup-artifacts')
|
|
24
24
|
.alias('cleanup-artifact')
|
|
25
|
-
.description('Evaluate and optionally apply artifact retention cleanup for
|
|
25
|
+
.description('Evaluate and optionally apply artifact retention cleanup for a FRAIM workspace or pack root')
|
|
26
26
|
.option('--user <email>', 'Active user email used only for user-scoped cleanup categories')
|
|
27
27
|
.option('--workspace-root <path>', 'Workspace root containing fraim/config.json')
|
|
28
|
-
.option('--root <path>', '
|
|
28
|
+
.option('--root <path>', 'Workspace root or explicit Manager/Org pack root; uses that root\'s retention policy')
|
|
29
29
|
.option('--apply', 'Delete eligible files. Without this flag the command performs a dry run.')
|
|
30
30
|
.action((options) => {
|
|
31
31
|
try {
|
|
@@ -348,4 +348,4 @@ class AIMentor {
|
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
350
|
exports.AIMentor = AIMentor;
|
|
351
|
-
AIMentor.COMPACT_PHASE_AUTHORITY = '**Use only the phases defined in this workflow.**';
|
|
351
|
+
AIMentor.COMPACT_PHASE_AUTHORITY = '**Use only the phases defined in this workflow.** Before phase work, read `get_fraim_file({ path: "orchestration/phase-authority.md" })` for phase closure, approval gates, and resumption.';
|
|
@@ -17,16 +17,19 @@
|
|
|
17
17
|
* after the existing quality enforcement block.
|
|
18
18
|
*/
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
-
exports.PHASE_START_WARN_PHASES = void 0;
|
|
21
20
|
exports.isSubmitPhase = isSubmitPhase;
|
|
22
21
|
exports.isRetrospectivePhase = isRetrospectivePhase;
|
|
23
22
|
exports.isAddressFeedbackCompletion = isAddressFeedbackCompletion;
|
|
24
23
|
exports.isDelegationGraphPhase = isDelegationGraphPhase;
|
|
25
24
|
exports.validateAddressFeedbackApproval = validateAddressFeedbackApproval;
|
|
26
25
|
exports.validateReviewActionCompleteness = validateReviewActionCompleteness;
|
|
26
|
+
exports.recordPhaseStart = recordPhaseStart;
|
|
27
|
+
exports.inspectPhaseCompletion = inspectPhaseCompletion;
|
|
28
|
+
exports.commitPhaseCompletion = commitPhaseCompletion;
|
|
27
29
|
exports.hasPhaseStarted = hasPhaseStarted;
|
|
28
30
|
exports.upsertPhaseStarted = upsertPhaseStarted;
|
|
29
31
|
exports.buildPhaseStartWarning = buildPhaseStartWarning;
|
|
32
|
+
exports.reviewHandoffNormalizationError = reviewHandoffNormalizationError;
|
|
30
33
|
exports.validateReviewHandoff = validateReviewHandoff;
|
|
31
34
|
exports.validateNextJobRecommendations = validateNextJobRecommendations;
|
|
32
35
|
exports.validateDelegationLedger = validateDelegationLedger;
|
|
@@ -175,11 +178,105 @@ function validateReviewActionCompleteness(reviewTarget, reviewActions, repo) {
|
|
|
175
178
|
}
|
|
176
179
|
return errors.length > 0 ? errors : null;
|
|
177
180
|
}
|
|
181
|
+
function isPhaseStartInstance(value) {
|
|
182
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
183
|
+
return false;
|
|
184
|
+
const instance = value;
|
|
185
|
+
return typeof instance.instanceId === 'string'
|
|
186
|
+
&& typeof instance.sessionId === 'string'
|
|
187
|
+
&& typeof instance.jobName === 'string'
|
|
188
|
+
&& typeof instance.phaseId === 'string'
|
|
189
|
+
&& typeof instance.startedAt === 'string';
|
|
190
|
+
}
|
|
191
|
+
function phaseContextMatches(instance, context) {
|
|
192
|
+
if (instance.sessionId !== context.sessionId
|
|
193
|
+
|| instance.jobName !== context.jobName
|
|
194
|
+
|| instance.phaseId !== context.phaseId
|
|
195
|
+
|| instance.resumeId !== context.resumeId) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
// A start without jobId is intentionally matchable to a later identified
|
|
199
|
+
// completion. When both calls identify a job, the identifiers must agree.
|
|
200
|
+
return !instance.jobId || instance.jobId === context.jobId;
|
|
201
|
+
}
|
|
202
|
+
function phaseInstancesFrom(marker) {
|
|
203
|
+
if (!marker || typeof marker !== 'object' || Array.isArray(marker))
|
|
204
|
+
return [];
|
|
205
|
+
const values = marker.phaseInstances;
|
|
206
|
+
return Array.isArray(values) ? values.filter(isPhaseStartInstance).map((value) => ({ ...value })) : [];
|
|
207
|
+
}
|
|
208
|
+
function nextPhaseInstanceId(instances, context) {
|
|
209
|
+
let ordinal = instances.length + 1;
|
|
210
|
+
const prefix = [context.sessionId, context.jobName, context.phaseId];
|
|
211
|
+
while (instances.some((instance) => instance.instanceId === JSON.stringify([...prefix, ordinal])))
|
|
212
|
+
ordinal++;
|
|
213
|
+
return JSON.stringify([...prefix, ordinal]);
|
|
214
|
+
}
|
|
215
|
+
/** Start a phase instance, refreshing an active start when instructions are requested again. */
|
|
216
|
+
function recordPhaseStart(marker, context) {
|
|
217
|
+
const instances = phaseInstancesFrom(marker);
|
|
218
|
+
// A repeated start refreshes the current instance; it must not leave an
|
|
219
|
+
// older unconsumed start available for a later re-entry.
|
|
220
|
+
const active = instances.find((instance) => !instance.completedAt
|
|
221
|
+
&& phaseContextMatches(instance, context));
|
|
222
|
+
if (active) {
|
|
223
|
+
active.startedAt = context.timestamp;
|
|
224
|
+
if (context.jobId)
|
|
225
|
+
active.jobId = context.jobId;
|
|
226
|
+
return { phaseInstances: instances };
|
|
227
|
+
}
|
|
228
|
+
const instanceId = nextPhaseInstanceId(instances, context);
|
|
229
|
+
instances.push({
|
|
230
|
+
sessionId: context.sessionId,
|
|
231
|
+
jobName: context.jobName,
|
|
232
|
+
phaseId: context.phaseId,
|
|
233
|
+
...(context.jobId ? { jobId: context.jobId } : {}),
|
|
234
|
+
...(context.resumeId ? { resumeId: context.resumeId } : {}),
|
|
235
|
+
instanceId,
|
|
236
|
+
startedAt: context.timestamp,
|
|
237
|
+
});
|
|
238
|
+
return { phaseInstances: instances };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Inspect a completion without mutating state. The caller must commit only
|
|
242
|
+
* after every other completion contract accepts the call, so rejected calls
|
|
243
|
+
* do not consume the valid starting instance.
|
|
244
|
+
*/
|
|
245
|
+
function inspectPhaseCompletion(marker, context) {
|
|
246
|
+
const instances = phaseInstancesFrom(marker);
|
|
247
|
+
const candidates = instances.filter((instance) => !instance.completedAt && phaseContextMatches(instance, context));
|
|
248
|
+
if (candidates.length === 1)
|
|
249
|
+
return { missingStart: false, instanceId: candidates[0].instanceId };
|
|
250
|
+
return { missingStart: true };
|
|
251
|
+
}
|
|
252
|
+
/** Commit an accepted completion, consuming its matched start or recording a flagged synthetic instance. */
|
|
253
|
+
function commitPhaseCompletion(marker, inspection, context) {
|
|
254
|
+
const instances = phaseInstancesFrom(marker);
|
|
255
|
+
if (inspection.instanceId) {
|
|
256
|
+
const matched = instances.find((instance) => instance.instanceId === inspection.instanceId);
|
|
257
|
+
if (matched) {
|
|
258
|
+
matched.completedAt = context.timestamp;
|
|
259
|
+
if (context.jobId)
|
|
260
|
+
matched.jobId = context.jobId;
|
|
261
|
+
return { phaseInstances: instances };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
instances.push({
|
|
265
|
+
sessionId: context.sessionId,
|
|
266
|
+
jobName: context.jobName,
|
|
267
|
+
phaseId: context.phaseId,
|
|
268
|
+
...(context.jobId ? { jobId: context.jobId } : {}),
|
|
269
|
+
...(context.resumeId ? { resumeId: context.resumeId } : {}),
|
|
270
|
+
instanceId: nextPhaseInstanceId(instances, context),
|
|
271
|
+
startedAt: context.timestamp,
|
|
272
|
+
completedAt: context.timestamp,
|
|
273
|
+
missingStart: true,
|
|
274
|
+
});
|
|
275
|
+
return { phaseInstances: instances };
|
|
276
|
+
}
|
|
178
277
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* missing key, or a malformed shape — this is a warn-only signal, not a
|
|
182
|
-
* required artifact.
|
|
278
|
+
* Legacy marker helpers retained for consumers that still inspect the old
|
|
279
|
+
* address-feedback shape. New tracking uses the instance-aware helpers above.
|
|
183
280
|
*/
|
|
184
281
|
function hasPhaseStarted(marker, phaseId) {
|
|
185
282
|
if (!marker || typeof marker !== 'object' || Array.isArray(marker))
|
|
@@ -190,11 +287,6 @@ function hasPhaseStarted(marker, phaseId) {
|
|
|
190
287
|
const value = startedPhases[phaseId];
|
|
191
288
|
return typeof value === 'string' && value.length > 0;
|
|
192
289
|
}
|
|
193
|
-
/**
|
|
194
|
-
* Returns a new marker with `phaseId` upserted to `timestamp`, preserving
|
|
195
|
-
* every other phase already recorded. Tolerates a missing or malformed
|
|
196
|
-
* existing marker by starting from an empty `startedPhases`.
|
|
197
|
-
*/
|
|
198
290
|
function upsertPhaseStarted(marker, phaseId, timestamp) {
|
|
199
291
|
const existing = marker && typeof marker === 'object' && !Array.isArray(marker)
|
|
200
292
|
? marker.startedPhases
|
|
@@ -205,18 +297,11 @@ function upsertPhaseStarted(marker, phaseId, timestamp) {
|
|
|
205
297
|
startedPhases[phaseId] = timestamp;
|
|
206
298
|
return { startedPhases };
|
|
207
299
|
}
|
|
208
|
-
/**
|
|
209
|
-
exports.PHASE_START_WARN_PHASES = ['address-feedback'];
|
|
210
|
-
/**
|
|
211
|
-
* Builds the warning prepended to an otherwise-valid address-feedback
|
|
212
|
-
* response when this run never called seekMentoring(status: "starting") for
|
|
213
|
-
* that phase. Warn-only, never a rejection: a lost marker (disk cleanup, a
|
|
214
|
-
* first-ever run before this shipped) must not strand a legitimate resume.
|
|
215
|
-
*/
|
|
300
|
+
/** Builds the warn-only notice for any phase completed without an on-arrival start. */
|
|
216
301
|
function buildPhaseStartWarning(phaseId) {
|
|
217
|
-
return (`⚠️ **Phase-start notice**:
|
|
218
|
-
|
|
219
|
-
`
|
|
302
|
+
return (`⚠️ **Phase-start notice**: phase \`${phaseId}\` completed without an on-arrival ` +
|
|
303
|
+
`\`seekMentoring({ currentPhase: "${phaseId}", status: "starting" })\` call in this session. ` +
|
|
304
|
+
`The missing call was flagged, but this completion remains accepted.\n\n`);
|
|
220
305
|
}
|
|
221
306
|
function isAbsoluteHttpUrl(url) {
|
|
222
307
|
if (typeof url !== 'string' || !url)
|
|
@@ -255,23 +340,49 @@ function validateReviewArtifactEntry(artifact, index) {
|
|
|
255
340
|
return errors;
|
|
256
341
|
}
|
|
257
342
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* Required minimum shape:
|
|
262
|
-
* {
|
|
263
|
-
* reviewRequired: boolean,
|
|
264
|
-
* artifacts: array,
|
|
265
|
-
* // when reviewRequired is true:
|
|
266
|
-
* reviewTarget: { type: "pull_request", label: string, url: string } | { type: "artifact_set", label: string },
|
|
267
|
-
* reviewActions: [{ kind: "approve", ... }, { kind: "request_changes", ... }],
|
|
268
|
-
* }
|
|
269
|
-
*
|
|
270
|
-
* Hub normalizers (server.ts + script.js) both read reviewTarget.type, not .kind.
|
|
271
|
-
* Using reviewTarget.kind produces a payload that passes validation but is silently
|
|
272
|
-
* un-renderable. This validator enforces the renderable contract so malformed payloads
|
|
273
|
-
* are rejected before phase advancement.
|
|
343
|
+
* Reports payloads the Hub cannot normalize, including artifact_set entries it
|
|
344
|
+
* would silently drop. This deliberately preserves the Hub's legacy defaults;
|
|
345
|
+
* it does not impose the stronger, phase-dependent submission contract.
|
|
274
346
|
*/
|
|
347
|
+
function reviewHandoffNormalizationError(value) {
|
|
348
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
349
|
+
return 'reviewHandoff must be an object';
|
|
350
|
+
}
|
|
351
|
+
const handoff = value;
|
|
352
|
+
if (typeof handoff.reviewRequired !== 'boolean') {
|
|
353
|
+
return 'reviewHandoff.reviewRequired must be a boolean';
|
|
354
|
+
}
|
|
355
|
+
// This is renderability, not the stronger submission-phase contract below.
|
|
356
|
+
// In particular legacy handoffs need not supply actions or an artifacts array.
|
|
357
|
+
if (!handoff.reviewRequired)
|
|
358
|
+
return null;
|
|
359
|
+
const target = handoff.reviewTarget;
|
|
360
|
+
const targetType = typeof target?.type === 'string' ? target.type.trim() : '';
|
|
361
|
+
if (targetType !== 'pull_request' && targetType !== 'artifact_set') {
|
|
362
|
+
return 'reviewHandoff.reviewTarget.type must be pull_request or artifact_set';
|
|
363
|
+
}
|
|
364
|
+
if (targetType === 'pull_request' && !isAbsoluteHttpUrl(target?.url)) {
|
|
365
|
+
return 'reviewHandoff.reviewTarget.url must be an absolute http or https URL';
|
|
366
|
+
}
|
|
367
|
+
// Match the host parser's object-artifact selection and Hub's path/URL
|
|
368
|
+
// normalization. A missing label/type is filled in by the Hub, not rejected.
|
|
369
|
+
const artifacts = Array.isArray(handoff.artifacts)
|
|
370
|
+
? handoff.artifacts.filter((artifact) => artifact && typeof artifact === 'object')
|
|
371
|
+
: [];
|
|
372
|
+
for (const [index, artifact] of artifacts.entries()) {
|
|
373
|
+
const hasPath = typeof artifact.path === 'string' && artifact.path.trim().length > 0;
|
|
374
|
+
const hasUrl = isAbsoluteHttpUrl(artifact.url);
|
|
375
|
+
if (targetType === 'pull_request') {
|
|
376
|
+
if (hasPath || hasUrl)
|
|
377
|
+
return 'reviewHandoff.artifacts must be empty for a pull_request target';
|
|
378
|
+
}
|
|
379
|
+
else if (!hasPath || hasUrl) {
|
|
380
|
+
return `reviewHandoff.artifacts[${index}] cannot be applied: an artifact_set requires a file path without an http or https URL`;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
/** Validate the complete submission contract, including required actions. */
|
|
275
386
|
function validateReviewHandoff(value) {
|
|
276
387
|
if (value === undefined || value === null) {
|
|
277
388
|
return ['evidence.reviewHandoff is missing'];
|
|
@@ -559,6 +670,23 @@ evidence: {
|
|
|
559
670
|
}
|
|
560
671
|
\`\`\``;
|
|
561
672
|
const FIELD_SCHEMAS = {
|
|
673
|
+
proposalSet: [
|
|
674
|
+
'{',
|
|
675
|
+
' "runScope": "manager" | "project" | "org",',
|
|
676
|
+
' "proposals": [{',
|
|
677
|
+
' "proposalId": "<id the journal records against>",',
|
|
678
|
+
' "claimKey": "<claim identity>", "family": "<learning family>", "revision": <int>,',
|
|
679
|
+
' "changeType": "new" | "update" | "cold",',
|
|
680
|
+
' "title": "<reader title>", "takeaway": "<reader takeaway>", "example": "<optional>",',
|
|
681
|
+
' "suggestedLevel": "manager" | "project" | "org", "recurrences": <int>,',
|
|
682
|
+
' "recurrencesAtLastReview": <int|null>, "domain": "<domain|null>",',
|
|
683
|
+
' "reopening": { "rejectedAt": "<date>", "reason": "<manager words>", "countAtRejection": <int> }',
|
|
684
|
+
' }],',
|
|
685
|
+
' "suppressed": [{ "claimKey": "...", "family": "...", "rejectedAt": "<date>",',
|
|
686
|
+
' "kind": "refused" | "superseded", "reason": "...", "newSourceCount": <int> }]',
|
|
687
|
+
'}',
|
|
688
|
+
'Trace, sourceRefs and scores are audit metadata and must not be sent.',
|
|
689
|
+
].join(String.fromCharCode(10)),
|
|
562
690
|
approved: ADDRESS_FEEDBACK_APPROVAL_SCHEMA,
|
|
563
691
|
reviewHandoff: REVIEW_HANDOFF_SCHEMA,
|
|
564
692
|
nextJobRecommendations: NEXT_JOB_RECOMMENDATIONS_SCHEMA,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Proposal Set Contract Validation (Issue #1857)
|
|
4
|
+
*
|
|
5
|
+
* The Hub renders the manager's learning review batch from this payload. Before
|
|
6
|
+
* this contract it rendered from prose: `tfParsePendingProposals` scanned an
|
|
7
|
+
* employee turn for a `## Pending Review` heading and `#### [P-LEVEL]` lines that
|
|
8
|
+
* no job or skill was ever instructed to emit, so the review surface #1876
|
|
9
|
+
* shipped rendered nothing for a real run.
|
|
10
|
+
*
|
|
11
|
+
* Two findings from the design spikes shape what this file checks.
|
|
12
|
+
*
|
|
13
|
+
* First, failing loudly is the behavior under test. A wire-shaped proposal whose
|
|
14
|
+
* field names did not match what the card reads rendered a card with its body
|
|
15
|
+
* silently missing and threw nothing. A contract that rejects the payload and
|
|
16
|
+
* names the field is recoverable; one that renders a degraded card is not.
|
|
17
|
+
*
|
|
18
|
+
* Second, the size caps are measured rather than chosen. Across 4,779 recorded
|
|
19
|
+
* `seekMentoring` calls in the raw host archives, the largest evidence object this
|
|
20
|
+
* system has ever carried is 6,044 bytes (median 506, p95 1,859). A proposal costs
|
|
21
|
+
* roughly 700 bytes once its 80-word reader budget and identity fields are counted,
|
|
22
|
+
* so a proposal set is the largest payload this channel will carry and the bound
|
|
23
|
+
* has to be explicit rather than discovered in production.
|
|
24
|
+
*
|
|
25
|
+
* Lives in src/core so the local MCP proxy, the Hub and the evals import the same
|
|
26
|
+
* validator, per architecture 4.2.1 — the same pattern as `handoff-contracts.ts`,
|
|
27
|
+
* `quality-evidence.ts` and `test-evidence-contract.ts`.
|
|
28
|
+
*
|
|
29
|
+
* Primary call site: src/local-mcp-server/stdio-server.ts seekMentoring handler.
|
|
30
|
+
* The Hub keeps its own normalizer as well; the two do different jobs. This one
|
|
31
|
+
* tells the agent what to fix in one round. The normalizer protects the render
|
|
32
|
+
* from a payload that reached the Hub by some other path.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.MAX_SUPPRESSED_PER_SET = exports.MAX_PROPOSALS_PER_SET = exports.PROPOSAL_CHANGE_TYPES = exports.SUPPRESSION_KINDS = exports.PROPOSAL_RUN_SCOPES = void 0;
|
|
36
|
+
exports.rankProposals = rankProposals;
|
|
37
|
+
exports.validateProposalSet = validateProposalSet;
|
|
38
|
+
/** Scopes a run can be asked for. Decides which corpus an approval lands in. */
|
|
39
|
+
exports.PROPOSAL_RUN_SCOPES = ['manager', 'project', 'org'];
|
|
40
|
+
/** How a claim came to be suppressed, so R7's report does not describe a
|
|
41
|
+
* claim the manager promoted to a rule as one they refused. */
|
|
42
|
+
exports.SUPPRESSION_KINDS = ['refused', 'superseded'];
|
|
43
|
+
/** What kind of change a proposal makes to the corpus. */
|
|
44
|
+
exports.PROPOSAL_CHANGE_TYPES = ['new', 'update', 'cold'];
|
|
45
|
+
/**
|
|
46
|
+
* Maximum proposals in one set. Derived from the measured envelope above, not
|
|
47
|
+
* chosen: at roughly 700 bytes per proposal this keeps a full set inside twice
|
|
48
|
+
* the largest evidence payload the channel has ever carried. Raising it should
|
|
49
|
+
* follow a re-measurement, which `test-1857-proposal-set-contract.ts` pins.
|
|
50
|
+
*/
|
|
51
|
+
exports.MAX_PROPOSALS_PER_SET = 12;
|
|
52
|
+
/** Maximum suppressed entries reported in one batch. Each is a single line. */
|
|
53
|
+
exports.MAX_SUPPRESSED_PER_SET = 25;
|
|
54
|
+
/** Reader-field length bound. The authoring skill already budgets 80 words across
|
|
55
|
+
* title and the four reader values; this is the byte-level backstop. */
|
|
56
|
+
const MAX_READER_FIELD = 600;
|
|
57
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
58
|
+
const isText = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
59
|
+
/**
|
|
60
|
+
* Fields that are audit metadata rather than review material. R10 is an invariant
|
|
61
|
+
* about influence, not storage: the manager's card is not a place for a score, a
|
|
62
|
+
* source path or an originating job to arrive by accident. Rejecting them at the
|
|
63
|
+
* boundary is cheaper than auditing every render site later.
|
|
64
|
+
*/
|
|
65
|
+
const FORBIDDEN_PROPOSAL_FIELDS = ['trace', 'sourceRefs', 'sourceHashes', 'content', 'score'];
|
|
66
|
+
function validateEntry(entry, index, errors) {
|
|
67
|
+
const at = `evidence.proposalSet.proposals[${index}]`;
|
|
68
|
+
if (!isObject(entry)) {
|
|
69
|
+
errors.push(`${at} must be an object`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const field of ['proposalId', 'claimKey', 'family']) {
|
|
73
|
+
if (!isText(entry[field]))
|
|
74
|
+
errors.push(`${at}.${field} must be a non-empty string`);
|
|
75
|
+
}
|
|
76
|
+
if (!Number.isInteger(entry.revision) || entry.revision < 1) {
|
|
77
|
+
errors.push(`${at}.revision must be a positive integer`);
|
|
78
|
+
}
|
|
79
|
+
for (const field of ['title', 'takeaway']) {
|
|
80
|
+
if (!isText(entry[field])) {
|
|
81
|
+
errors.push(`${at}.${field} must be a non-empty string; the card renders it`);
|
|
82
|
+
}
|
|
83
|
+
else if (entry[field].length > MAX_READER_FIELD) {
|
|
84
|
+
errors.push(`${at}.${field} exceeds ${MAX_READER_FIELD} characters`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (entry.example !== undefined && typeof entry.example !== 'string') {
|
|
88
|
+
errors.push(`${at}.example must be a string when present`);
|
|
89
|
+
}
|
|
90
|
+
if (entry.suggestionReason !== undefined) {
|
|
91
|
+
if (typeof entry.suggestionReason !== 'string') {
|
|
92
|
+
errors.push(`${at}.suggestionReason must be a string when present`);
|
|
93
|
+
}
|
|
94
|
+
else if (entry.suggestionReason.length > MAX_READER_FIELD) {
|
|
95
|
+
errors.push(`${at}.suggestionReason exceeds ${MAX_READER_FIELD} characters`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!exports.PROPOSAL_RUN_SCOPES.includes(entry.suggestedLevel)) {
|
|
99
|
+
errors.push(`${at}.suggestedLevel must be one of ${exports.PROPOSAL_RUN_SCOPES.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
if (!exports.PROPOSAL_CHANGE_TYPES.includes(entry.changeType)) {
|
|
102
|
+
errors.push(`${at}.changeType must be one of ${exports.PROPOSAL_CHANGE_TYPES.join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
if (!Number.isInteger(entry.recurrences) || entry.recurrences < 0) {
|
|
105
|
+
errors.push(`${at}.recurrences must be a non-negative integer`);
|
|
106
|
+
}
|
|
107
|
+
for (const field of FORBIDDEN_PROPOSAL_FIELDS) {
|
|
108
|
+
if (entry[field] !== undefined) {
|
|
109
|
+
errors.push(`${at}.${field} is audit metadata and must not be sent to the review surface`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (entry.reopening !== undefined) {
|
|
113
|
+
const r = entry.reopening;
|
|
114
|
+
const ra = `${at}.reopening`;
|
|
115
|
+
if (!isObject(r)) {
|
|
116
|
+
errors.push(`${ra} must be an object`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
if (!isText(r.rejectedAt))
|
|
120
|
+
errors.push(`${ra}.rejectedAt must be a non-empty string`);
|
|
121
|
+
if (!isText(r.reason)) {
|
|
122
|
+
errors.push(`${ra}.reason must be a non-empty string; a reopening quotes the original decision`);
|
|
123
|
+
}
|
|
124
|
+
if (!Number.isInteger(r.countAtRejection) || r.countAtRejection < 0) {
|
|
125
|
+
errors.push(`${ra}.countAtRejection must be a non-negative integer`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function validateSuppressed(entry, index, errors) {
|
|
131
|
+
const at = `evidence.proposalSet.suppressed[${index}]`;
|
|
132
|
+
if (!isObject(entry)) {
|
|
133
|
+
errors.push(`${at} must be an object`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const field of ['claimKey', 'family', 'rejectedAt', 'reason']) {
|
|
137
|
+
if (!isText(entry[field]))
|
|
138
|
+
errors.push(`${at}.${field} must be a non-empty string`);
|
|
139
|
+
}
|
|
140
|
+
if (!exports.SUPPRESSION_KINDS.includes(entry.kind)) {
|
|
141
|
+
errors.push(`${at}.kind must be one of ${exports.SUPPRESSION_KINDS.join(', ')}`);
|
|
142
|
+
}
|
|
143
|
+
if (!Number.isInteger(entry.newSourceCount) || entry.newSourceCount < 0) {
|
|
144
|
+
errors.push(`${at}.newSourceCount must be a non-negative integer`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Validates `evidence.proposalSet`. Returns null when valid or absent, or an array
|
|
149
|
+
* of messages each naming the exact field, so the agent fixes it in one round
|
|
150
|
+
* rather than guessing.
|
|
151
|
+
*
|
|
152
|
+
* Absent is valid: a review phase with nothing to review emits no set. An empty
|
|
153
|
+
* `proposals` array is also valid and means the same thing, which keeps a run that
|
|
154
|
+
* suppressed everything able to report its suppressions.
|
|
155
|
+
*/
|
|
156
|
+
/**
|
|
157
|
+
* Order a batch by accumulated evidence strength (Issue #1857 R3).
|
|
158
|
+
*
|
|
159
|
+
* An undecided proposal is not a chore that expires; it comes back, and it comes
|
|
160
|
+
* back stronger or it does not. A flat list makes every row look equally
|
|
161
|
+
* deserving of the manager's attention, which is how a claim with eleven
|
|
162
|
+
* occurrences ends up below one with two because it happened to be authored
|
|
163
|
+
* later. Ordering is the whole reason "leave it for now" is a position rather
|
|
164
|
+
* than a deferral: the next batch shows the manager what changed.
|
|
165
|
+
*
|
|
166
|
+
* Recurrences is the primary key because it is the accumulated evidence itself.
|
|
167
|
+
* Growth since the last review breaks ties, so between two claims at the same
|
|
168
|
+
* count the one that is still happening leads. `proposalId` breaks the remainder,
|
|
169
|
+
* so the same batch always renders in the same order and a manager re-opening a
|
|
170
|
+
* run does not find the rows rearranged.
|
|
171
|
+
*
|
|
172
|
+
* Applied on the Hub's own read rather than trusted from the agent: the order the
|
|
173
|
+
* manager sees should not depend on how a model happened to sort its output.
|
|
174
|
+
*/
|
|
175
|
+
function rankProposals(proposals) {
|
|
176
|
+
const growth = (p) => typeof p.recurrencesAtLastReview === 'number' ? p.recurrences - p.recurrencesAtLastReview : 0;
|
|
177
|
+
return [...proposals].sort((a, b) => b.recurrences - a.recurrences
|
|
178
|
+
|| growth(b) - growth(a)
|
|
179
|
+
|| a.proposalId.localeCompare(b.proposalId));
|
|
180
|
+
}
|
|
181
|
+
function validateProposalSet(raw) {
|
|
182
|
+
if (raw === undefined || raw === null)
|
|
183
|
+
return null;
|
|
184
|
+
const errors = [];
|
|
185
|
+
if (!isObject(raw)) {
|
|
186
|
+
return ['evidence.proposalSet must be an object'];
|
|
187
|
+
}
|
|
188
|
+
if (!exports.PROPOSAL_RUN_SCOPES.includes(raw.runScope)) {
|
|
189
|
+
errors.push(`evidence.proposalSet.runScope must be one of ${exports.PROPOSAL_RUN_SCOPES.join(', ')}`);
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(raw.proposals)) {
|
|
192
|
+
errors.push('evidence.proposalSet.proposals must be an array');
|
|
193
|
+
}
|
|
194
|
+
else if (raw.proposals.length > exports.MAX_PROPOSALS_PER_SET) {
|
|
195
|
+
errors.push(`evidence.proposalSet.proposals holds ${raw.proposals.length} entries, past the ` +
|
|
196
|
+
`${exports.MAX_PROPOSALS_PER_SET} a single batch may carry; present the strongest first and ` +
|
|
197
|
+
'report the remainder so the manager knows the batch was split');
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
raw.proposals.forEach((entry, index) => validateEntry(entry, index, errors));
|
|
201
|
+
}
|
|
202
|
+
if (raw.suppressed !== undefined) {
|
|
203
|
+
if (!Array.isArray(raw.suppressed)) {
|
|
204
|
+
errors.push('evidence.proposalSet.suppressed must be an array when present');
|
|
205
|
+
}
|
|
206
|
+
else if (raw.suppressed.length > exports.MAX_SUPPRESSED_PER_SET) {
|
|
207
|
+
errors.push(`evidence.proposalSet.suppressed exceeds ${exports.MAX_SUPPRESSED_PER_SET} entries`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
raw.suppressed.forEach((entry, index) => validateSuppressed(entry, index, errors));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return errors.length > 0 ? errors : null;
|
|
214
|
+
}
|