@yemi33/minions 0.1.2424 → 0.1.2425
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/bin/install-internal-minions.js +1209 -0
- package/bin/minions.js +75 -9
- package/dashboard/docs/typography.md +27 -12
- package/dashboard/js/command-center.js +13 -11
- package/dashboard/js/live-stream.js +1 -1
- package/dashboard/js/memory-search.js +388 -43
- package/dashboard/js/modal-qa.js +5 -5
- package/dashboard/js/qa.js +39 -26
- package/dashboard/js/refresh.js +37 -18
- package/dashboard/js/render-agents.js +14 -18
- package/dashboard/js/render-dispatch.js +14 -74
- package/dashboard/js/render-inbox.js +3 -3
- package/dashboard/js/render-meetings.js +8 -8
- package/dashboard/js/render-other.js +45 -20
- package/dashboard/js/render-pipelines.js +6 -6
- package/dashboard/js/render-plans.js +14 -14
- package/dashboard/js/render-prd.js +46 -46
- package/dashboard/js/render-prs.js +9 -65
- package/dashboard/js/render-schedules.js +5 -5
- package/dashboard/js/render-skills.js +6 -6
- package/dashboard/js/render-utils.js +4 -4
- package/dashboard/js/render-watches.js +4 -16
- package/dashboard/js/render-work-items.js +95 -107
- package/dashboard/js/settings.js +175 -74
- package/dashboard/layout.html +3 -2
- package/dashboard/pages/inbox.html +1 -1
- package/dashboard/pages/work.html +1 -1
- package/dashboard/shared/model-display.js +9 -0
- package/dashboard/shared/watches-source.js +43 -0
- package/dashboard/shared/welcome-popup.js +259 -0
- package/dashboard/slim/body.html +6 -6
- package/dashboard/slim/js/chat.js +4 -2
- package/dashboard/slim/js/history.js +10 -3
- package/dashboard/slim/js/modals-tiles.js +85 -16
- package/dashboard/slim/js/status.js +12 -16
- package/dashboard/slim/styles.css +24 -4
- package/dashboard/styles.css +62 -27
- package/dashboard-build.js +8 -2
- package/dashboard.js +3854 -2514
- package/docs/README.md +6 -4
- package/docs/api-errors.md +144 -0
- package/docs/auto-discovery.md +83 -61
- package/docs/capture-demos.js +173 -30
- package/docs/command-center.md +5 -1
- package/docs/completion-reports.md +127 -11
- package/docs/constants.md +15 -3
- package/docs/constellation-style-telemetry.md +2 -2
- package/docs/copilot-cli-schema.md +32 -22
- package/docs/cross-repo-plans.md +30 -30
- package/docs/demo/memory-system.html +1 -1
- package/docs/deprecated.json +132 -18
- package/docs/diagnostics-memory.md +8 -4
- package/docs/human-vs-automated.md +2 -2
- package/docs/internal-install.md +190 -0
- package/docs/kb-pr3223-cascade-archiving.md +16 -0
- package/docs/kb-pr696-merge-conflict-docs.md +23 -0
- package/docs/kb-sweep.md +2 -2
- package/docs/keep-processes.md +8 -1
- package/docs/live-checkout-mode.md +1 -1
- package/docs/managed-spawn.md +21 -3
- package/docs/named-agents.md +3 -2
- package/docs/plan-lifecycle.md +32 -32
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/qa-runbook-lifecycle.md +67 -51
- package/docs/qa-runbooks.md +2 -2
- package/docs/runtime-adapters.md +101 -50
- package/docs/security.md +28 -0
- package/docs/self-improvement.md +47 -13
- package/docs/skills.md +29 -0
- package/docs/slim-ux/concepts.md +2 -2
- package/docs/specs/agent-configurability.md +43 -41
- package/docs/specs/agent-rename.md +13 -13
- package/docs/team-memory.md +283 -14
- package/docs/tutorials/03-plan-driven-feature.md +3 -3
- package/docs/tutorials/04-runtimes-and-harness.md +18 -21
- package/docs/tutorials/08-operations-and-recovery.md +3 -2
- package/docs/tutorials/README.md +4 -3
- package/docs/visual-evidence-ci.md +103 -0
- package/docs/watches.md +35 -30
- package/docs/worktree-lifecycle.md +151 -13
- package/engine/acp-transport.js +1 -0
- package/engine/ado.js +80 -33
- package/engine/agent-api-validation.js +571 -0
- package/engine/agent-worker-pool.js +7 -6
- package/engine/api-contracts/agent-content.js +761 -0
- package/engine/api-contracts/cc-ops.js +219 -0
- package/engine/api-contracts/config-runtime.js +419 -0
- package/engine/api-contracts/core.js +138 -0
- package/engine/api-contracts/index.js +506 -0
- package/engine/api-contracts/orchestration.js +602 -0
- package/engine/api-contracts/pull-requests.js +323 -0
- package/engine/api-contracts/qa-process.js +764 -0
- package/engine/api-contracts/work-plan-prd.js +494 -0
- package/engine/api-validation.js +543 -0
- package/engine/cc-api-validation.js +586 -0
- package/engine/cc-worker-pool.js +9 -1
- package/engine/cleanup.js +393 -271
- package/engine/cli.js +65 -7
- package/engine/comment-classifier.js +70 -5
- package/engine/consolidation.js +123 -5
- package/engine/content-api-validation.js +606 -0
- package/engine/create-pr-worktree.js +22 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
- package/engine/dispatch.js +33 -0
- package/engine/distribution.js +189 -0
- package/engine/execution-model.js +59 -0
- package/engine/features.js +12 -0
- package/engine/github.js +67 -25
- package/engine/harness-context.js +313 -0
- package/engine/inbox-store.js +1 -1
- package/engine/kb-sweep.js +161 -38
- package/engine/keep-process-sweep.js +43 -3
- package/engine/lifecycle.js +1066 -165
- package/engine/llm.js +4 -0
- package/engine/managed-spawn.js +463 -48
- package/engine/meeting.js +197 -34
- package/engine/memory-retrieval.js +280 -0
- package/engine/memory-store.js +821 -38
- package/engine/model-discovery.js +17 -3
- package/engine/pipeline.js +802 -14
- package/engine/plan-prd-validation.js +745 -0
- package/engine/playbook.js +206 -17
- package/engine/pooled-agent-process.js +6 -1
- package/engine/pr-action.js +1 -1
- package/engine/pr-issue-validation.js +653 -0
- package/engine/pr-resolve.js +75 -13
- package/engine/prd-store.js +39 -6
- package/engine/preflight.js +7 -2
- package/engine/process-utils.js +79 -31
- package/engine/projects.js +198 -12
- package/engine/promotion.js +371 -0
- package/engine/qa-from-prd.js +3 -0
- package/engine/qa-process-validation.js +572 -0
- package/engine/qa-runbooks.js +41 -37
- package/engine/qa-runners/maestro.js +11 -7
- package/engine/qa-runners/playwright.js +37 -14
- package/engine/qa-runners.js +9 -10
- package/engine/qa-runs.js +124 -11
- package/engine/qa-sessions.js +139 -40
- package/engine/quarantine-refs.js +81 -11
- package/engine/queries.js +163 -9
- package/engine/review-learning-backfill.js +482 -0
- package/engine/review-learning.js +1236 -0
- package/engine/runtimes/claude.js +60 -2
- package/engine/runtimes/codex.js +57 -16
- package/engine/runtimes/contract.js +21 -0
- package/engine/runtimes/copilot.js +51 -3
- package/engine/runtimes/index.js +1 -0
- package/engine/scheduler.js +201 -32
- package/engine/settings-validation.js +909 -0
- package/engine/shared.js +705 -208
- package/engine/spawn-agent.js +48 -21
- package/engine/steering-constraints.js +31 -0
- package/engine/steering-store.js +10 -2
- package/engine/steering.js +14 -2
- package/engine/timeout.js +404 -111
- package/engine/untrusted-fence.js +24 -10
- package/engine/watch-actions.js +7 -2
- package/engine/watches.js +353 -51
- package/engine/work-item-validation.js +561 -0
- package/engine/work-items-store.js +61 -1
- package/engine/worktree-gc.js +656 -37
- package/engine/worktree-preflight.js +154 -0
- package/engine.js +2068 -342
- package/package.json +1 -1
- package/playbooks/_pr-description-audit.md +88 -41
- package/playbooks/build-fix-complex.md +3 -3
- package/playbooks/docs.md +2 -2
- package/playbooks/fix.md +18 -18
- package/playbooks/implement.md +18 -18
- package/playbooks/qa-session-draft.md +10 -12
- package/playbooks/qa-session-execute.md +16 -12
- package/playbooks/qa-validate.md +8 -7
- package/playbooks/shared-rules.md +45 -0
- package/playbooks/test.md +4 -0
- package/prompts/cc-system.md +1 -1
- package/skills/check-self-authored-review-comment/SKILL.md +2 -2
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
// engine/review-learning.js — bounded domain primitives for review-derived memory.
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const {
|
|
5
|
+
normalizeIsoTimestamp,
|
|
6
|
+
parseCanonicalPrId,
|
|
7
|
+
redactSecrets,
|
|
8
|
+
truncateTextBytes,
|
|
9
|
+
} = require('./shared');
|
|
10
|
+
|
|
11
|
+
const REVIEW_LEARNING_SOURCE_TYPE = 'review-learning';
|
|
12
|
+
const REVIEW_LEARNING_DISPOSITIONS = Object.freeze({
|
|
13
|
+
ACCEPTED_FIXED: 'accepted-fixed',
|
|
14
|
+
SATISFIED_ELSEWHERE: 'satisfied-elsewhere',
|
|
15
|
+
INVALID: 'invalid',
|
|
16
|
+
STALE: 'stale',
|
|
17
|
+
DEFERRED: 'deferred',
|
|
18
|
+
OUT_OF_SCOPE_FOLLOW_UP: 'out-of-scope-follow-up',
|
|
19
|
+
});
|
|
20
|
+
const REVIEW_LEARNING_EVENT_TYPES = Object.freeze([
|
|
21
|
+
'candidate-created',
|
|
22
|
+
'candidate-updated',
|
|
23
|
+
'created',
|
|
24
|
+
'retrieved',
|
|
25
|
+
'applied',
|
|
26
|
+
'considered-not-applicable',
|
|
27
|
+
'contradicted',
|
|
28
|
+
'retracted',
|
|
29
|
+
'restored',
|
|
30
|
+
'pinned',
|
|
31
|
+
'superseded',
|
|
32
|
+
'repeated-finding',
|
|
33
|
+
'promotion-requested',
|
|
34
|
+
]);
|
|
35
|
+
const REVIEW_LEARNING_APPLICATION_OUTCOMES = Object.freeze([
|
|
36
|
+
'applied',
|
|
37
|
+
'considered-not-applicable',
|
|
38
|
+
'contradicted',
|
|
39
|
+
]);
|
|
40
|
+
// Human operator controls map to matching lifecycle events. `pin` and
|
|
41
|
+
// `restore` reactivate a lesson (retaining provenance); `retract` quarantines
|
|
42
|
+
// it. Emitting these keeps the operator audit trail aligned with the
|
|
43
|
+
// agent-driven application/contradiction events.
|
|
44
|
+
const REVIEW_LEARNING_HUMAN_ACTION_EVENTS = Object.freeze({
|
|
45
|
+
pin: 'pinned',
|
|
46
|
+
retract: 'retracted',
|
|
47
|
+
restore: 'restored',
|
|
48
|
+
});
|
|
49
|
+
const REVIEW_LEARNING_BOUNDS = Object.freeze({
|
|
50
|
+
projectBytes: 120,
|
|
51
|
+
prIdBytes: 320,
|
|
52
|
+
sourceKeyBytes: 240,
|
|
53
|
+
findingKeyBytes: 640,
|
|
54
|
+
areaBytes: 360,
|
|
55
|
+
titleBytes: 240,
|
|
56
|
+
bodyBytes: 4096,
|
|
57
|
+
ruleBytes: 1024,
|
|
58
|
+
rules: 8,
|
|
59
|
+
facets: 20,
|
|
60
|
+
facetBytes: 300,
|
|
61
|
+
evidence: 20,
|
|
62
|
+
evidenceBytes: 400,
|
|
63
|
+
metadataBytes: 4096,
|
|
64
|
+
metadataStringBytes: 768,
|
|
65
|
+
metadataKeys: 32,
|
|
66
|
+
metadataItems: 32,
|
|
67
|
+
metadataEntries: 128,
|
|
68
|
+
metadataDepth: 3,
|
|
69
|
+
selectedIds: 32,
|
|
70
|
+
selectedIdBytes: 160,
|
|
71
|
+
selectedIdsBytes: 4096,
|
|
72
|
+
decisionTraceEntries: 64,
|
|
73
|
+
decisionReasons: 8,
|
|
74
|
+
decisionReasonBytes: 96,
|
|
75
|
+
decisionTraceBytes: 8192,
|
|
76
|
+
queryBytes: 2048,
|
|
77
|
+
eventKeyBytes: 64,
|
|
78
|
+
refBytes: 320,
|
|
79
|
+
commentSources: 64,
|
|
80
|
+
commentAuthorBytes: 160,
|
|
81
|
+
completionLearnings: 8,
|
|
82
|
+
completionApplications: 32,
|
|
83
|
+
completionLearningsBytes: 65536,
|
|
84
|
+
completionApplicationsBytes: 16384,
|
|
85
|
+
completionMemoryIdBytes: 160,
|
|
86
|
+
applicationEvidenceBytes: 400,
|
|
87
|
+
diagnostics: 8,
|
|
88
|
+
diagnosticBytes: 600,
|
|
89
|
+
transitionEvidence: 4,
|
|
90
|
+
lifecycleEvents: 50000,
|
|
91
|
+
retrievalRuns: 5000,
|
|
92
|
+
// Retention (P-1ed2fb5f): unresolved candidates expire after 180 days;
|
|
93
|
+
// compact lifecycle events are retained for at most one year (in addition to
|
|
94
|
+
// the 50000-row cap). These windows are deliberately decoupled from KB file
|
|
95
|
+
// TTL — active procedural lessons persist until superseded or retracted.
|
|
96
|
+
candidateExpiryMs: 180 * 24 * 60 * 60 * 1000,
|
|
97
|
+
lifecycleEventMaxAgeMs: 365 * 24 * 60 * 60 * 1000,
|
|
98
|
+
applicableTopK: 4,
|
|
99
|
+
applicableBytes: 4096,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const DISPOSITIONS = new Set(Object.values(REVIEW_LEARNING_DISPOSITIONS));
|
|
103
|
+
const POSITIVE_DISPOSITIONS = new Set([
|
|
104
|
+
REVIEW_LEARNING_DISPOSITIONS.ACCEPTED_FIXED,
|
|
105
|
+
REVIEW_LEARNING_DISPOSITIONS.SATISFIED_ELSEWHERE,
|
|
106
|
+
]);
|
|
107
|
+
const EVENT_TYPES = new Set(REVIEW_LEARNING_EVENT_TYPES);
|
|
108
|
+
const APPLICATION_OUTCOMES = new Set(REVIEW_LEARNING_APPLICATION_OUTCOMES);
|
|
109
|
+
const AUDIENCES = new Set(['author', 'project-team']);
|
|
110
|
+
const PROHIBITED_FIELD_RE = /^(?:body|content|text|prompt|messages?|conversation|transcript|chain[-_]?of[-_]?thought|reasoning|stdout|stderr|raw(?:prompt|comment|reviewer|transcript))$|(?:raw|prompt|comment|reviewer|transcript|conversation).*(?:body|content|text|payload|output)|(?:chain[-_]?of[-_]?thought|reasoning)/i;
|
|
111
|
+
const DECISION_KEYS = new Set(['id', 'selected', 'rank', 'score', 'reason', 'reasons', 'decision', 'outcome']);
|
|
112
|
+
|
|
113
|
+
const CANDIDATE_TRANSITIONS = Object.freeze({
|
|
114
|
+
[REVIEW_LEARNING_DISPOSITIONS.ACCEPTED_FIXED]: Object.freeze({
|
|
115
|
+
status: 'superseded',
|
|
116
|
+
candidateState: 'promoted',
|
|
117
|
+
terminal: true,
|
|
118
|
+
requiresLesson: true,
|
|
119
|
+
}),
|
|
120
|
+
[REVIEW_LEARNING_DISPOSITIONS.SATISFIED_ELSEWHERE]: Object.freeze({
|
|
121
|
+
status: 'superseded',
|
|
122
|
+
candidateState: 'promoted',
|
|
123
|
+
terminal: true,
|
|
124
|
+
requiresLesson: true,
|
|
125
|
+
}),
|
|
126
|
+
[REVIEW_LEARNING_DISPOSITIONS.INVALID]: Object.freeze({
|
|
127
|
+
status: 'retracted',
|
|
128
|
+
candidateState: 'closed-invalid',
|
|
129
|
+
terminal: true,
|
|
130
|
+
requiresLesson: false,
|
|
131
|
+
}),
|
|
132
|
+
[REVIEW_LEARNING_DISPOSITIONS.STALE]: Object.freeze({
|
|
133
|
+
status: 'expired',
|
|
134
|
+
candidateState: 'closed-stale',
|
|
135
|
+
terminal: true,
|
|
136
|
+
requiresLesson: false,
|
|
137
|
+
}),
|
|
138
|
+
[REVIEW_LEARNING_DISPOSITIONS.DEFERRED]: Object.freeze({
|
|
139
|
+
status: 'candidate',
|
|
140
|
+
candidateState: 'deferred',
|
|
141
|
+
terminal: false,
|
|
142
|
+
requiresLesson: false,
|
|
143
|
+
}),
|
|
144
|
+
[REVIEW_LEARNING_DISPOSITIONS.OUT_OF_SCOPE_FOLLOW_UP]: Object.freeze({
|
|
145
|
+
status: 'candidate',
|
|
146
|
+
candidateState: 'follow-up',
|
|
147
|
+
terminal: false,
|
|
148
|
+
requiresLesson: false,
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function _byteLength(value) {
|
|
153
|
+
return Buffer.byteLength(String(value || ''), 'utf8');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _required(value, label) {
|
|
157
|
+
const text = String(value == null ? '' : value).trim();
|
|
158
|
+
if (!text) throw new TypeError(`${label} is required`);
|
|
159
|
+
return text;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _boundedIdentifier(value, maxBytes, label, pattern = /^[a-z0-9][a-z0-9._:/#|@+-]*$/i) {
|
|
163
|
+
const text = _required(value, label);
|
|
164
|
+
if (_byteLength(text) > maxBytes) throw new TypeError(`${label} exceeds ${maxBytes} bytes`);
|
|
165
|
+
if (!pattern.test(text)) throw new TypeError(`${label} has an invalid format`);
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _optionalIdentifier(value, maxBytes, label) {
|
|
170
|
+
if (value == null || String(value).trim() === '') return null;
|
|
171
|
+
return _boundedIdentifier(value, maxBytes, label);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _shortHash(value) {
|
|
175
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _ruleHash(value) {
|
|
179
|
+
return `sha256:${crypto.createHash('sha256').update(String(value)).digest('hex')}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function redactBoundedText(value, maxBytes, label = 'text') {
|
|
183
|
+
const limit = Math.max(0, Math.floor(Number(maxBytes) || 0));
|
|
184
|
+
if (!limit) return '';
|
|
185
|
+
const redacted = redactSecrets(String(value == null ? '' : value));
|
|
186
|
+
return truncateTextBytes(redacted.trim(), limit);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeDisposition(value) {
|
|
190
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
191
|
+
if (normalized === 'already-resolved') return REVIEW_LEARNING_DISPOSITIONS.SATISFIED_ELSEWHERE;
|
|
192
|
+
if (!DISPOSITIONS.has(normalized)) throw new TypeError(`invalid review-learning disposition: ${value}`);
|
|
193
|
+
return normalized;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeProjectKey(value) {
|
|
197
|
+
const project = _required(value, 'review-learning project')
|
|
198
|
+
.toLowerCase()
|
|
199
|
+
.replace(/\s+/g, '-');
|
|
200
|
+
return _boundedIdentifier(
|
|
201
|
+
project,
|
|
202
|
+
REVIEW_LEARNING_BOUNDS.projectBytes,
|
|
203
|
+
'review-learning project',
|
|
204
|
+
/^[a-z0-9][a-z0-9._-]*$/,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeCanonicalPrId(value) {
|
|
209
|
+
const input = _required(value, 'canonical PR id');
|
|
210
|
+
const parsed = parseCanonicalPrId(input);
|
|
211
|
+
if (!parsed || !Number.isSafeInteger(parsed.prNumber) || parsed.prNumber < 1) {
|
|
212
|
+
throw new TypeError(`invalid canonical PR id: ${input}`);
|
|
213
|
+
}
|
|
214
|
+
const separator = parsed.scope.indexOf(':');
|
|
215
|
+
const host = parsed.scope.slice(0, separator);
|
|
216
|
+
const segments = parsed.scope.slice(separator + 1).split('/').map(segment => (
|
|
217
|
+
segment.replace(/%20/gi, ' ').trim().toLowerCase()
|
|
218
|
+
));
|
|
219
|
+
let expectedSegments = 0;
|
|
220
|
+
if (host === 'github') expectedSegments = 2;
|
|
221
|
+
else if (host === 'ado') expectedSegments = 3;
|
|
222
|
+
const invalidSegment = segments.some(segment => (
|
|
223
|
+
!segment
|
|
224
|
+
|| /[|#\u0000-\u001f\u007f]/.test(segment)
|
|
225
|
+
|| (host === 'github' && !/^[a-z0-9][a-z0-9._-]*$/.test(segment))
|
|
226
|
+
));
|
|
227
|
+
if (!expectedSegments || segments.length !== expectedSegments || invalidSegment) {
|
|
228
|
+
throw new TypeError(`invalid canonical PR id: ${input}`);
|
|
229
|
+
}
|
|
230
|
+
const normalized = `${host}:${segments.join('/')}#${parsed.prNumber}`;
|
|
231
|
+
return _boundedIdentifier(
|
|
232
|
+
normalized,
|
|
233
|
+
REVIEW_LEARNING_BOUNDS.prIdBytes,
|
|
234
|
+
'canonical PR id',
|
|
235
|
+
/^(?:github|ado):[^|#\u0000-\u001f\u007f]+#[1-9]\d*$/,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeFindingSourceKey(value) {
|
|
240
|
+
const input = _required(value, 'finding source key').toLowerCase();
|
|
241
|
+
const parts = input.split(':');
|
|
242
|
+
if (parts.length < 3 || !['github', 'ado'].includes(parts[0])) {
|
|
243
|
+
throw new TypeError('finding source key must be host-namespaced with github: or ado:');
|
|
244
|
+
}
|
|
245
|
+
for (const part of parts) {
|
|
246
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(part)) {
|
|
247
|
+
throw new TypeError(`invalid finding source key segment: ${part}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (_byteLength(input) > REVIEW_LEARNING_BOUNDS.sourceKeyBytes) {
|
|
251
|
+
throw new TypeError(`finding source key exceeds ${REVIEW_LEARNING_BOUNDS.sourceKeyBytes} bytes`);
|
|
252
|
+
}
|
|
253
|
+
return input;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function _normalizeTimestamp(value) {
|
|
257
|
+
return normalizeIsoTimestamp(value);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function hashReviewCommentContent(content) {
|
|
261
|
+
return `sha256:${crypto.createHash('sha256').update(String(content || ''), 'utf8').digest('hex')}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildReviewCommentSource(input = {}) {
|
|
265
|
+
const host = String(input.host || '').trim().toLowerCase().replace(/^gh$/, 'github');
|
|
266
|
+
const commentId = _boundedIdentifier(input.commentId, 160, 'review comment id');
|
|
267
|
+
const commentType = String(input.commentType || '').trim().toLowerCase();
|
|
268
|
+
let key;
|
|
269
|
+
let threadId = null;
|
|
270
|
+
|
|
271
|
+
if (host === 'github') {
|
|
272
|
+
if (commentType !== 'issue' && commentType !== 'review') {
|
|
273
|
+
throw new TypeError(`invalid GitHub review comment type: ${input.commentType}`);
|
|
274
|
+
}
|
|
275
|
+
key = `github:${commentType}-comment:${commentId.toLowerCase()}`;
|
|
276
|
+
} else if (host === 'ado') {
|
|
277
|
+
threadId = _boundedIdentifier(input.threadId, 160, 'ADO review thread id').toLowerCase();
|
|
278
|
+
key = `ado:thread:${threadId}:comment:${commentId.toLowerCase()}`;
|
|
279
|
+
} else {
|
|
280
|
+
throw new TypeError(`invalid review comment host: ${input.host}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
key: normalizeFindingSourceKey(key),
|
|
285
|
+
...(host === 'github' ? { commentType } : { threadId, commentType: commentType || 'text' }),
|
|
286
|
+
author: redactBoundedText(
|
|
287
|
+
input.author || 'unknown',
|
|
288
|
+
REVIEW_LEARNING_BOUNDS.commentAuthorBytes,
|
|
289
|
+
'review comment author',
|
|
290
|
+
),
|
|
291
|
+
createdAt: _normalizeTimestamp(input.createdAt),
|
|
292
|
+
updatedAt: _normalizeTimestamp(input.updatedAt || input.createdAt),
|
|
293
|
+
contentHash: hashReviewCommentContent(input.content),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeReviewCommentSources(sources) {
|
|
298
|
+
if (!Array.isArray(sources)) return [];
|
|
299
|
+
const byKey = new Map();
|
|
300
|
+
for (const source of sources) {
|
|
301
|
+
if (!source || typeof source !== 'object') continue;
|
|
302
|
+
const key = normalizeFindingSourceKey(source.key);
|
|
303
|
+
const githubMatch = key.match(/^github:(issue|review)-comment:([^:]+)$/);
|
|
304
|
+
const adoMatch = key.match(/^ado:thread:([^:]+):comment:([^:]+)$/);
|
|
305
|
+
if (!githubMatch && !adoMatch) throw new TypeError(`unsupported review comment source key: ${key}`);
|
|
306
|
+
const contentHash = String(source.contentHash || '').trim().toLowerCase();
|
|
307
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(contentHash)) {
|
|
308
|
+
throw new TypeError(`invalid review comment content hash for ${key}`);
|
|
309
|
+
}
|
|
310
|
+
const normalized = {
|
|
311
|
+
key,
|
|
312
|
+
...(githubMatch
|
|
313
|
+
? { commentType: githubMatch[1] }
|
|
314
|
+
: {
|
|
315
|
+
threadId: adoMatch[1],
|
|
316
|
+
commentType: redactBoundedText(source.commentType || 'text', 48, 'ADO comment type').toLowerCase(),
|
|
317
|
+
}),
|
|
318
|
+
author: redactBoundedText(
|
|
319
|
+
source.author || 'unknown',
|
|
320
|
+
REVIEW_LEARNING_BOUNDS.commentAuthorBytes,
|
|
321
|
+
'review comment author',
|
|
322
|
+
),
|
|
323
|
+
createdAt: _normalizeTimestamp(source.createdAt),
|
|
324
|
+
updatedAt: _normalizeTimestamp(source.updatedAt || source.createdAt),
|
|
325
|
+
contentHash,
|
|
326
|
+
};
|
|
327
|
+
byKey.set(key, normalized);
|
|
328
|
+
}
|
|
329
|
+
return [...byKey.values()]
|
|
330
|
+
.sort((a, b) => (
|
|
331
|
+
String(a.updatedAt || a.createdAt || '').localeCompare(String(b.updatedAt || b.createdAt || ''))
|
|
332
|
+
|| a.key.localeCompare(b.key)
|
|
333
|
+
))
|
|
334
|
+
.slice(-REVIEW_LEARNING_BOUNDS.commentSources);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function selectReviewCommentSourceSnapshot(currentFeedback, observedSources) {
|
|
338
|
+
const current = normalizeReviewCommentSources(currentFeedback?.commentSources);
|
|
339
|
+
if (currentFeedback?.pendingFix && String(currentFeedback.feedbackContent || '').trim() && current.length > 0) {
|
|
340
|
+
return current;
|
|
341
|
+
}
|
|
342
|
+
return normalizeReviewCommentSources(observedSources);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function migrateLegacyReviewCommentEdits(previous, sourceKeys) {
|
|
346
|
+
if (!previous || typeof previous !== 'object' || Array.isArray(previous)) return {};
|
|
347
|
+
const keys = [...new Set((Array.isArray(sourceKeys) ? sourceKeys : [])
|
|
348
|
+
.map(normalizeFindingSourceKey))];
|
|
349
|
+
const bareIds = keys.map(key => key.slice(key.lastIndexOf(':') + 1));
|
|
350
|
+
const counts = new Map();
|
|
351
|
+
for (const id of bareIds) counts.set(id, (counts.get(id) || 0) + 1);
|
|
352
|
+
const migrated = {};
|
|
353
|
+
for (let index = 0; index < keys.length; index++) {
|
|
354
|
+
const key = keys[index];
|
|
355
|
+
const bareId = bareIds[index];
|
|
356
|
+
if (previous[key] != null) {
|
|
357
|
+
migrated[key] = previous[key];
|
|
358
|
+
} else if (counts.get(bareId) === 1 && previous[bareId] != null) {
|
|
359
|
+
migrated[key] = previous[bareId];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return migrated;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function buildFindingKey({ project, prId, pr, sourceKey } = {}) {
|
|
366
|
+
const normalizedProject = normalizeProjectKey(project);
|
|
367
|
+
const normalizedPr = normalizeCanonicalPrId(prId || pr);
|
|
368
|
+
const normalizedSource = normalizeFindingSourceKey(sourceKey);
|
|
369
|
+
const prHost = normalizedPr.slice(0, normalizedPr.indexOf(':'));
|
|
370
|
+
const sourceHost = normalizedSource.slice(0, normalizedSource.indexOf(':'));
|
|
371
|
+
if (prHost !== sourceHost) {
|
|
372
|
+
throw new TypeError(`finding source host ${sourceHost} does not match PR host ${prHost}`);
|
|
373
|
+
}
|
|
374
|
+
const findingKey = `${normalizedProject}|${normalizedPr}|${normalizedSource}`;
|
|
375
|
+
if (_byteLength(findingKey) > REVIEW_LEARNING_BOUNDS.findingKeyBytes) {
|
|
376
|
+
throw new TypeError(`finding key exceeds ${REVIEW_LEARNING_BOUNDS.findingKeyBytes} bytes`);
|
|
377
|
+
}
|
|
378
|
+
return findingKey;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function normalizeFindingKey(value) {
|
|
382
|
+
const parts = _required(value, 'finding key').split('|');
|
|
383
|
+
if (parts.length !== 3) throw new TypeError('finding key must contain project, PR, and source identity');
|
|
384
|
+
return buildFindingKey({ project: parts[0], prId: parts[1], sourceKey: parts[2] });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function _assertFindingScope(findingKey, project, prId) {
|
|
388
|
+
const [findingProject, findingPr] = findingKey.split('|');
|
|
389
|
+
if (findingProject !== project) {
|
|
390
|
+
throw new TypeError(`finding key project ${findingProject} does not match record scope ${project}`);
|
|
391
|
+
}
|
|
392
|
+
if (findingPr !== prId) {
|
|
393
|
+
throw new TypeError(`finding key PR ${findingPr} does not match record PR ${prId}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function _normalizeFile(value) {
|
|
398
|
+
let file = String(value == null ? '' : value).trim().replace(/\\/g, '/').toLowerCase();
|
|
399
|
+
file = file.replace(/:\d+(?::\d+)?$/, '').replace(/^\.\//, '').replace(/\/+/g, '/');
|
|
400
|
+
if (!file || file.startsWith('/') || /^[a-z]:\//.test(file)) return '';
|
|
401
|
+
const parts = file.split('/');
|
|
402
|
+
if (parts.some(part => !part || part === '.' || part === '..')) return '';
|
|
403
|
+
file = file.replace(/\s+/g, '-');
|
|
404
|
+
if (!/^[a-z0-9@+_.\-/]+$/.test(file)) return '';
|
|
405
|
+
return _byteLength(file) <= REVIEW_LEARNING_BOUNDS.facetBytes ? file : '';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function _normalizeSymbol(value) {
|
|
409
|
+
const symbol = String(value == null ? '' : value).trim().toLowerCase().replace(/\s+/g, '');
|
|
410
|
+
if (!symbol || !/^[a-z_$][a-z0-9_$#.:/<>,~+-]*$/.test(symbol)) return '';
|
|
411
|
+
return _byteLength(symbol) <= REVIEW_LEARNING_BOUNDS.facetBytes ? symbol : '';
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function _normalizeTag(value) {
|
|
415
|
+
const tag = String(value == null ? '' : value).trim().toLowerCase()
|
|
416
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
417
|
+
.replace(/^-+|-+$/g, '');
|
|
418
|
+
if (!tag) return '';
|
|
419
|
+
return _byteLength(tag) <= 64 ? tag : '';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function _normalizeList(values, normalizer, max = REVIEW_LEARNING_BOUNDS.facets) {
|
|
423
|
+
const input = Array.isArray(values) ? values : (values == null || values === '' ? [] : [values]);
|
|
424
|
+
const normalized = [...new Set(input.map(normalizer).filter(Boolean))].sort();
|
|
425
|
+
return normalized.slice(0, max);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function normalizeReviewFacets(input = {}) {
|
|
429
|
+
const files = _normalizeList(input.files, _normalizeFile);
|
|
430
|
+
const symbols = _normalizeList(input.symbols, _normalizeSymbol);
|
|
431
|
+
const tags = _normalizeList(input.tags, _normalizeTag);
|
|
432
|
+
return { files, symbols, tags, area: normalizeReviewArea({ files, symbols, tags }) };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function normalizeReviewArea(input = {}) {
|
|
436
|
+
if (typeof input === 'string') {
|
|
437
|
+
const area = input.trim().toLowerCase().replace(/\\/g, '/');
|
|
438
|
+
if (area === 'general') return area;
|
|
439
|
+
if (!/^(?:symbol|file|tags):[a-z0-9@+_$#.,:|<>=~\-/]+$/.test(area)) {
|
|
440
|
+
throw new TypeError(`invalid review-learning area: ${input}`);
|
|
441
|
+
}
|
|
442
|
+
if (_byteLength(area) > REVIEW_LEARNING_BOUNDS.areaBytes) {
|
|
443
|
+
throw new TypeError(`review-learning area exceeds ${REVIEW_LEARNING_BOUNDS.areaBytes} bytes`);
|
|
444
|
+
}
|
|
445
|
+
return area;
|
|
446
|
+
}
|
|
447
|
+
const symbols = _normalizeList(input.symbols, _normalizeSymbol);
|
|
448
|
+
if (symbols.length) return `symbol:${symbols[0]}`;
|
|
449
|
+
const files = _normalizeList(input.files, _normalizeFile);
|
|
450
|
+
const tags = _normalizeList(input.tags, _normalizeTag);
|
|
451
|
+
if (files.length) return `file:${files[0]}${tags.length ? `|tags:${tags.join(',')}` : ''}`;
|
|
452
|
+
if (tags.length) return `tags:${tags.join(',')}`;
|
|
453
|
+
return 'general';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function _assertSafeFieldName(key) {
|
|
457
|
+
if (!/^[a-z][a-z0-9_.-]{0,63}$/i.test(key)) throw new TypeError(`invalid metadata field: ${key}`);
|
|
458
|
+
if (PROHIBITED_FIELD_RE.test(key)) throw new TypeError(`prohibited review-learning field: ${key}`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function assertNoProhibitedReviewPayload(value, depth = 0) {
|
|
462
|
+
if (depth > REVIEW_LEARNING_BOUNDS.metadataDepth + 2 || value == null) return;
|
|
463
|
+
if (Array.isArray(value)) {
|
|
464
|
+
for (const entry of value) assertNoProhibitedReviewPayload(entry, depth + 1);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (typeof value !== 'object') return;
|
|
468
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
469
|
+
_assertSafeFieldName(key);
|
|
470
|
+
assertNoProhibitedReviewPayload(nested, depth + 1);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function _normalizeMetadataValue(value, depth, state) {
|
|
475
|
+
if (depth > REVIEW_LEARNING_BOUNDS.metadataDepth) {
|
|
476
|
+
throw new TypeError(`review-learning metadata exceeds depth ${REVIEW_LEARNING_BOUNDS.metadataDepth}`);
|
|
477
|
+
}
|
|
478
|
+
state.entries += 1;
|
|
479
|
+
if (state.entries > REVIEW_LEARNING_BOUNDS.metadataEntries) {
|
|
480
|
+
throw new TypeError(`review-learning metadata exceeds ${REVIEW_LEARNING_BOUNDS.metadataEntries} entries`);
|
|
481
|
+
}
|
|
482
|
+
if (value == null || typeof value === 'boolean') return value;
|
|
483
|
+
if (typeof value === 'number') {
|
|
484
|
+
if (!Number.isFinite(value)) throw new TypeError('review-learning metadata numbers must be finite');
|
|
485
|
+
return value;
|
|
486
|
+
}
|
|
487
|
+
if (typeof value === 'string') {
|
|
488
|
+
return redactBoundedText(
|
|
489
|
+
value,
|
|
490
|
+
REVIEW_LEARNING_BOUNDS.metadataStringBytes,
|
|
491
|
+
'review-learning metadata string',
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (Array.isArray(value)) {
|
|
495
|
+
if (value.length > REVIEW_LEARNING_BOUNDS.metadataItems) {
|
|
496
|
+
throw new TypeError(`review-learning metadata arrays are limited to ${REVIEW_LEARNING_BOUNDS.metadataItems} items`);
|
|
497
|
+
}
|
|
498
|
+
return value.map(item => _normalizeMetadataValue(item, depth + 1, state));
|
|
499
|
+
}
|
|
500
|
+
if (typeof value === 'object') {
|
|
501
|
+
const entries = Object.entries(value).filter(([, nested]) => nested !== undefined);
|
|
502
|
+
if (entries.length > REVIEW_LEARNING_BOUNDS.metadataKeys) {
|
|
503
|
+
throw new TypeError(`review-learning metadata objects are limited to ${REVIEW_LEARNING_BOUNDS.metadataKeys} keys`);
|
|
504
|
+
}
|
|
505
|
+
const normalized = {};
|
|
506
|
+
for (const [key, nested] of entries) {
|
|
507
|
+
_assertSafeFieldName(key);
|
|
508
|
+
normalized[key] = _normalizeMetadataValue(nested, depth + 1, state);
|
|
509
|
+
}
|
|
510
|
+
return normalized;
|
|
511
|
+
}
|
|
512
|
+
throw new TypeError(`unsupported review-learning metadata value: ${typeof value}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function normalizeReviewMetadata(metadata = {}) {
|
|
516
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
517
|
+
throw new TypeError('review-learning metadata must be an object');
|
|
518
|
+
}
|
|
519
|
+
const normalized = _normalizeMetadataValue(metadata, 0, { entries: 0 });
|
|
520
|
+
if (_byteLength(JSON.stringify(normalized)) > REVIEW_LEARNING_BOUNDS.metadataBytes) {
|
|
521
|
+
throw new TypeError(`review-learning metadata exceeds ${REVIEW_LEARNING_BOUNDS.metadataBytes} bytes`);
|
|
522
|
+
}
|
|
523
|
+
return normalized;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function _normalizeAgent(value, label) {
|
|
527
|
+
return _boundedIdentifier(
|
|
528
|
+
String(value || '').trim().toLowerCase(),
|
|
529
|
+
96,
|
|
530
|
+
label,
|
|
531
|
+
/^[a-z][a-z0-9-]*$/,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function _normalizeEvidence(values) {
|
|
536
|
+
const input = Array.isArray(values) ? values : (values == null || values === '' ? [] : [values]);
|
|
537
|
+
return [...new Set(input.map(value => redactBoundedText(
|
|
538
|
+
value,
|
|
539
|
+
REVIEW_LEARNING_BOUNDS.evidenceBytes,
|
|
540
|
+
'review-learning evidence',
|
|
541
|
+
)).filter(Boolean))].slice(0, REVIEW_LEARNING_BOUNDS.evidence);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function classifyExactEvidence(values) {
|
|
545
|
+
const kinds = new Set();
|
|
546
|
+
for (const value of _normalizeEvidence(values)) {
|
|
547
|
+
if (/(?:^|[\s`("'[])(?:[a-z]:[\\/])?[\w@+./\\-]+\.[a-z0-9]+:\d+(?::\d+)?\b/i.test(value)) {
|
|
548
|
+
kinds.add('current-code');
|
|
549
|
+
}
|
|
550
|
+
if (/\bcommit(?:\s+|[:#])?[0-9a-f]{7,40}\b/i.test(value)) {
|
|
551
|
+
kinds.add('commit');
|
|
552
|
+
}
|
|
553
|
+
if (/(?:^|[\s`("'[])(?:\.\/)?(?:test|tests)\/[\w@+./-]+\.(?:test|spec)\.[a-z0-9]+(?::\d+(?::\d+)?)?\b/i.test(value)) {
|
|
554
|
+
kinds.add('test');
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return [...kinds].sort();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function _assertCompletionObject(value, label) {
|
|
561
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
562
|
+
throw new TypeError(`${label} must be an object`);
|
|
563
|
+
}
|
|
564
|
+
return value;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function _assertCompletionFields(value, allowed, label) {
|
|
568
|
+
for (const key of Object.keys(value)) {
|
|
569
|
+
if (!allowed.has(key)) throw new TypeError(`${label} has unsupported field: ${key}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function _completionString(value, label, maxBytes, { required = true } = {}) {
|
|
574
|
+
if (typeof value !== 'string') throw new TypeError(`${label} must be a string`);
|
|
575
|
+
const normalized = redactBoundedText(value, maxBytes, label);
|
|
576
|
+
if (required && !normalized) throw new TypeError(`${label} is required`);
|
|
577
|
+
return normalized;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function _completionIdentifier(value, label, maxBytes, pattern) {
|
|
581
|
+
if (typeof value !== 'string') throw new TypeError(`${label} must be a string`);
|
|
582
|
+
return _boundedIdentifier(value, maxBytes, label, pattern);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function _completionStringList(values, label, maxItems, maxBytes, normalizer, { required = false } = {}) {
|
|
586
|
+
if (!Array.isArray(values)) throw new TypeError(`${label} must be an array`);
|
|
587
|
+
if (values.length > maxItems) throw new TypeError(`${label} is limited to ${maxItems} entries`);
|
|
588
|
+
const normalized = [];
|
|
589
|
+
const seen = new Set();
|
|
590
|
+
for (const value of values) {
|
|
591
|
+
if (typeof value !== 'string') throw new TypeError(`${label} entries must be strings`);
|
|
592
|
+
const item = normalizer
|
|
593
|
+
? normalizer(value)
|
|
594
|
+
: redactBoundedText(value, maxBytes, `${label} entry`);
|
|
595
|
+
if (!item) throw new TypeError(`${label} contains an invalid entry`);
|
|
596
|
+
if (seen.has(item)) continue;
|
|
597
|
+
seen.add(item);
|
|
598
|
+
normalized.push(item);
|
|
599
|
+
}
|
|
600
|
+
if (required && normalized.length === 0) throw new TypeError(`${label} requires at least one entry`);
|
|
601
|
+
return normalized;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function _normalizeCompletionFile(value) {
|
|
605
|
+
return _normalizeFile(value);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function _normalizeCompletionSymbol(value) {
|
|
609
|
+
const symbol = String(value == null ? '' : value).trim();
|
|
610
|
+
if (/\s/.test(symbol)) return '';
|
|
611
|
+
return _normalizeSymbol(symbol);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function _normalizeCompletionTag(value) {
|
|
615
|
+
return _normalizeTag(value);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function _normalizeCompletionMemoryId(value, label) {
|
|
619
|
+
return _completionIdentifier(
|
|
620
|
+
value,
|
|
621
|
+
label,
|
|
622
|
+
REVIEW_LEARNING_BOUNDS.completionMemoryIdBytes,
|
|
623
|
+
/^M-[a-z0-9][a-z0-9._:-]*$/i,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function _normalizeCompletionContext(context = {}) {
|
|
628
|
+
const project = normalizeProjectKey(context.project);
|
|
629
|
+
const prId = normalizeCanonicalPrId(context.prId || context.pr);
|
|
630
|
+
const authorAgent = _normalizeAgent(context.authorAgent, 'completion author agent');
|
|
631
|
+
const rawAllowed = context.allowedSourceKeys instanceof Set
|
|
632
|
+
? [...context.allowedSourceKeys]
|
|
633
|
+
: context.allowedSourceKeys;
|
|
634
|
+
if (!Array.isArray(rawAllowed)) throw new TypeError('allowed review-learning sources must be an array');
|
|
635
|
+
const allowedSourceKeys = new Set(rawAllowed.map((value) => {
|
|
636
|
+
if (typeof value !== 'string') throw new TypeError('allowed review-learning source keys must be strings');
|
|
637
|
+
const sourceKey = normalizeFindingSourceKey(value);
|
|
638
|
+
buildFindingKey({ project, prId, sourceKey });
|
|
639
|
+
return sourceKey;
|
|
640
|
+
}));
|
|
641
|
+
return { project, prId, authorAgent, allowedSourceKeys };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function _normalizeCompletionLearning(input, context) {
|
|
645
|
+
_assertCompletionObject(input, 'review learning');
|
|
646
|
+
assertNoProhibitedReviewPayload(input);
|
|
647
|
+
_assertCompletionFields(input, new Set([
|
|
648
|
+
'rule',
|
|
649
|
+
'disposition',
|
|
650
|
+
'source',
|
|
651
|
+
'project',
|
|
652
|
+
'authorAgent',
|
|
653
|
+
'applicability',
|
|
654
|
+
'files',
|
|
655
|
+
'symbols',
|
|
656
|
+
'tags',
|
|
657
|
+
'evidence',
|
|
658
|
+
'confidence',
|
|
659
|
+
'repeatedOf',
|
|
660
|
+
]), 'review learning');
|
|
661
|
+
|
|
662
|
+
const source = _assertCompletionObject(input.source, 'review learning source');
|
|
663
|
+
_assertCompletionFields(source, new Set(['pr', 'sourceKey']), 'review learning source');
|
|
664
|
+
if (typeof source.pr !== 'string') throw new TypeError('review learning source PR must be a string');
|
|
665
|
+
if (typeof source.sourceKey !== 'string') throw new TypeError('review learning source key must be a string');
|
|
666
|
+
const sourcePr = normalizeCanonicalPrId(source.pr);
|
|
667
|
+
if (sourcePr !== context.prId) {
|
|
668
|
+
throw new TypeError(`review learning source does not match dispatch PR ${context.prId}`);
|
|
669
|
+
}
|
|
670
|
+
const sourceKey = normalizeFindingSourceKey(source.sourceKey);
|
|
671
|
+
if (!context.allowedSourceKeys.has(sourceKey)) {
|
|
672
|
+
throw new TypeError('review learning source key is not an allowed source for this dispatch');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (typeof input.project !== 'string') throw new TypeError('review learning project must be a string');
|
|
676
|
+
const project = normalizeProjectKey(input.project);
|
|
677
|
+
if (project !== context.project) {
|
|
678
|
+
throw new TypeError(`review learning project does not match dispatch project ${context.project}`);
|
|
679
|
+
}
|
|
680
|
+
if (typeof input.authorAgent !== 'string') throw new TypeError('review learning author agent must be a string');
|
|
681
|
+
const authorAgent = _normalizeAgent(input.authorAgent, 'review learning author agent');
|
|
682
|
+
if (authorAgent !== context.authorAgent) {
|
|
683
|
+
throw new TypeError(`review learning author agent does not match dispatch author agent ${context.authorAgent}`);
|
|
684
|
+
}
|
|
685
|
+
if (typeof input.applicability !== 'string') {
|
|
686
|
+
throw new TypeError('review learning applicability must be a string');
|
|
687
|
+
}
|
|
688
|
+
const applicability = input.applicability.trim().toLowerCase();
|
|
689
|
+
if (!AUDIENCES.has(applicability)) {
|
|
690
|
+
throw new TypeError(`invalid review learning applicability: ${input.applicability}`);
|
|
691
|
+
}
|
|
692
|
+
if (typeof input.disposition !== 'string') {
|
|
693
|
+
throw new TypeError('review learning disposition must be a string');
|
|
694
|
+
}
|
|
695
|
+
const disposition = normalizeDisposition(input.disposition);
|
|
696
|
+
if (typeof input.confidence !== 'number' || !Number.isFinite(input.confidence)) {
|
|
697
|
+
throw new TypeError('review learning confidence must be a finite number');
|
|
698
|
+
}
|
|
699
|
+
if (input.confidence < 0 || input.confidence > 1) {
|
|
700
|
+
throw new TypeError('review learning confidence must be between 0 and 1');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const normalized = {
|
|
704
|
+
rule: _completionString(input.rule, 'review learning rule', REVIEW_LEARNING_BOUNDS.ruleBytes),
|
|
705
|
+
disposition,
|
|
706
|
+
source: { pr: sourcePr, sourceKey },
|
|
707
|
+
project,
|
|
708
|
+
authorAgent,
|
|
709
|
+
applicability,
|
|
710
|
+
files: _completionStringList(
|
|
711
|
+
input.files,
|
|
712
|
+
'review learning files',
|
|
713
|
+
REVIEW_LEARNING_BOUNDS.facets,
|
|
714
|
+
REVIEW_LEARNING_BOUNDS.facetBytes,
|
|
715
|
+
_normalizeCompletionFile,
|
|
716
|
+
),
|
|
717
|
+
symbols: _completionStringList(
|
|
718
|
+
input.symbols,
|
|
719
|
+
'review learning symbols',
|
|
720
|
+
REVIEW_LEARNING_BOUNDS.facets,
|
|
721
|
+
REVIEW_LEARNING_BOUNDS.facetBytes,
|
|
722
|
+
_normalizeCompletionSymbol,
|
|
723
|
+
),
|
|
724
|
+
tags: _completionStringList(
|
|
725
|
+
input.tags,
|
|
726
|
+
'review learning tags',
|
|
727
|
+
REVIEW_LEARNING_BOUNDS.facets,
|
|
728
|
+
64,
|
|
729
|
+
_normalizeCompletionTag,
|
|
730
|
+
),
|
|
731
|
+
evidence: _completionStringList(
|
|
732
|
+
input.evidence,
|
|
733
|
+
'review learning evidence',
|
|
734
|
+
REVIEW_LEARNING_BOUNDS.evidence,
|
|
735
|
+
REVIEW_LEARNING_BOUNDS.evidenceBytes,
|
|
736
|
+
null,
|
|
737
|
+
{ required: true },
|
|
738
|
+
),
|
|
739
|
+
confidence: input.confidence,
|
|
740
|
+
};
|
|
741
|
+
if (input.repeatedOf != null) {
|
|
742
|
+
normalized.repeatedOf = _normalizeCompletionMemoryId(input.repeatedOf, 'review learning repeatedOf');
|
|
743
|
+
}
|
|
744
|
+
return normalized;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function normalizeCompletionReviewLearnings(values, context = {}) {
|
|
748
|
+
if (!Array.isArray(values)) throw new TypeError('reviewLearnings must be an array');
|
|
749
|
+
if (values.length > REVIEW_LEARNING_BOUNDS.completionLearnings) {
|
|
750
|
+
throw new TypeError(`reviewLearnings accepts at most ${REVIEW_LEARNING_BOUNDS.completionLearnings} entries`);
|
|
751
|
+
}
|
|
752
|
+
const normalizedContext = _normalizeCompletionContext(context);
|
|
753
|
+
const normalized = values.map(value => _normalizeCompletionLearning(value, normalizedContext));
|
|
754
|
+
if (_byteLength(JSON.stringify(normalized)) > REVIEW_LEARNING_BOUNDS.completionLearningsBytes) {
|
|
755
|
+
throw new TypeError(`reviewLearnings exceeds ${REVIEW_LEARNING_BOUNDS.completionLearningsBytes} bytes`);
|
|
756
|
+
}
|
|
757
|
+
return normalized;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function normalizeCompletionReviewLearningApplications(values) {
|
|
761
|
+
if (!Array.isArray(values)) throw new TypeError('reviewLearningApplications must be an array');
|
|
762
|
+
if (values.length > REVIEW_LEARNING_BOUNDS.completionApplications) {
|
|
763
|
+
throw new TypeError(`reviewLearningApplications accepts at most ${REVIEW_LEARNING_BOUNDS.completionApplications} entries`);
|
|
764
|
+
}
|
|
765
|
+
const seen = new Set();
|
|
766
|
+
const normalized = values.map((input) => {
|
|
767
|
+
_assertCompletionObject(input, 'review learning application');
|
|
768
|
+
assertNoProhibitedReviewPayload(input);
|
|
769
|
+
_assertCompletionFields(input, new Set(['id', 'outcome', 'evidence']), 'review learning application');
|
|
770
|
+
const id = _normalizeCompletionMemoryId(input.id, 'review learning application id');
|
|
771
|
+
if (seen.has(id)) throw new TypeError(`duplicate review learning application id: ${id}`);
|
|
772
|
+
seen.add(id);
|
|
773
|
+
if (typeof input.outcome !== 'string') {
|
|
774
|
+
throw new TypeError('review learning application outcome must be a string');
|
|
775
|
+
}
|
|
776
|
+
const outcome = input.outcome.trim().toLowerCase();
|
|
777
|
+
if (!APPLICATION_OUTCOMES.has(outcome)) {
|
|
778
|
+
throw new TypeError(`invalid review learning application outcome: ${input.outcome}`);
|
|
779
|
+
}
|
|
780
|
+
if (input.evidence == null) throw new TypeError('review learning application evidence is required');
|
|
781
|
+
const application = {
|
|
782
|
+
id,
|
|
783
|
+
outcome,
|
|
784
|
+
evidence: _completionString(
|
|
785
|
+
input.evidence,
|
|
786
|
+
'review learning application evidence',
|
|
787
|
+
REVIEW_LEARNING_BOUNDS.applicationEvidenceBytes,
|
|
788
|
+
),
|
|
789
|
+
};
|
|
790
|
+
return application;
|
|
791
|
+
});
|
|
792
|
+
if (_byteLength(JSON.stringify(normalized)) > REVIEW_LEARNING_BOUNDS.completionApplicationsBytes) {
|
|
793
|
+
throw new TypeError(`reviewLearningApplications exceeds ${REVIEW_LEARNING_BOUNDS.completionApplicationsBytes} bytes`);
|
|
794
|
+
}
|
|
795
|
+
return normalized;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function _normalizeRules(input) {
|
|
799
|
+
const values = Array.isArray(input.rules)
|
|
800
|
+
? input.rules
|
|
801
|
+
: (input.rule == null ? [] : [input.rule]);
|
|
802
|
+
if (!values.length) throw new TypeError('at least one review-learning rule is required');
|
|
803
|
+
if (values.length > REVIEW_LEARNING_BOUNDS.rules) {
|
|
804
|
+
throw new TypeError(`review-learning rules are limited to ${REVIEW_LEARNING_BOUNDS.rules}`);
|
|
805
|
+
}
|
|
806
|
+
const rules = [...new Set(values.map(value => redactBoundedText(
|
|
807
|
+
value,
|
|
808
|
+
REVIEW_LEARNING_BOUNDS.ruleBytes,
|
|
809
|
+
'review-learning rule',
|
|
810
|
+
)).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
811
|
+
if (!rules.length) throw new TypeError('at least one non-empty review-learning rule is required');
|
|
812
|
+
return rules;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function _buildLessonMetadata(base, collections) {
|
|
816
|
+
let metadata = normalizeReviewMetadata({
|
|
817
|
+
...base,
|
|
818
|
+
ruleHashes: [],
|
|
819
|
+
files: [],
|
|
820
|
+
symbols: [],
|
|
821
|
+
tags: [],
|
|
822
|
+
evidence: [],
|
|
823
|
+
});
|
|
824
|
+
for (const [key, values] of Object.entries(collections)) {
|
|
825
|
+
for (const value of values) {
|
|
826
|
+
try {
|
|
827
|
+
metadata = normalizeReviewMetadata({
|
|
828
|
+
...metadata,
|
|
829
|
+
[key]: [...metadata[key], value],
|
|
830
|
+
});
|
|
831
|
+
} catch (err) {
|
|
832
|
+
if (!/metadata exceeds \d+ bytes/i.test(String(err?.message || ''))) throw err;
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return metadata;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function buildCandidateMemoryRecord(input = {}) {
|
|
841
|
+
assertNoProhibitedReviewPayload(input);
|
|
842
|
+
const findingKey = normalizeFindingKey(input.findingKey);
|
|
843
|
+
const project = normalizeProjectKey(input.project);
|
|
844
|
+
const prId = normalizeCanonicalPrId(input.prId || input.pr);
|
|
845
|
+
_assertFindingScope(findingKey, project, prId);
|
|
846
|
+
const authorAgent = _normalizeAgent(input.authorAgent, 'candidate author agent');
|
|
847
|
+
const reviewerAgent = _normalizeAgent(input.reviewerAgent, 'candidate reviewer agent');
|
|
848
|
+
const area = normalizeReviewArea(input.area || input);
|
|
849
|
+
const sourceHash = input.sourceHash
|
|
850
|
+
? _boundedIdentifier(input.sourceHash, 160, 'candidate source hash')
|
|
851
|
+
: null;
|
|
852
|
+
const metadata = normalizeReviewMetadata({
|
|
853
|
+
...(input.metadata || {}),
|
|
854
|
+
kind: 'review-learning-candidate',
|
|
855
|
+
findingKey,
|
|
856
|
+
prId,
|
|
857
|
+
authorAgent,
|
|
858
|
+
reviewerAgent,
|
|
859
|
+
sourceHash,
|
|
860
|
+
area,
|
|
861
|
+
candidateState: 'unverified',
|
|
862
|
+
workItemId: input.workItemId || null,
|
|
863
|
+
dispatchId: input.dispatchId || null,
|
|
864
|
+
});
|
|
865
|
+
const title = `Review finding candidate ${_shortHash(findingKey)}`;
|
|
866
|
+
const body = redactBoundedText(
|
|
867
|
+
`An actionable review finding (${findingKey}) is awaiting verified resolution. `
|
|
868
|
+
+ 'Verify the current code and record a canonical disposition before creating reusable guidance.',
|
|
869
|
+
REVIEW_LEARNING_BOUNDS.bodyBytes,
|
|
870
|
+
'review-learning candidate body',
|
|
871
|
+
);
|
|
872
|
+
return {
|
|
873
|
+
memoryType: 'procedural',
|
|
874
|
+
scopeType: 'project',
|
|
875
|
+
scopeKey: project,
|
|
876
|
+
title,
|
|
877
|
+
body,
|
|
878
|
+
tags: ['review-learning', 'candidate'],
|
|
879
|
+
sourceType: REVIEW_LEARNING_SOURCE_TYPE,
|
|
880
|
+
sourceRef: findingKey,
|
|
881
|
+
trust: 'system',
|
|
882
|
+
importance: 0.4,
|
|
883
|
+
confidence: 0.25,
|
|
884
|
+
status: 'candidate',
|
|
885
|
+
metadata,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function buildLessonMemoryRecord(input = {}) {
|
|
890
|
+
assertNoProhibitedReviewPayload(input);
|
|
891
|
+
const findingKey = normalizeFindingKey(input.findingKey);
|
|
892
|
+
const project = normalizeProjectKey(input.project);
|
|
893
|
+
const prId = normalizeCanonicalPrId(input.prId || input.pr);
|
|
894
|
+
_assertFindingScope(findingKey, project, prId);
|
|
895
|
+
const disposition = normalizeDisposition(input.disposition);
|
|
896
|
+
if (!POSITIVE_DISPOSITIONS.has(disposition)) {
|
|
897
|
+
throw new TypeError(`active review-learning lessons require a positive disposition, got ${disposition}`);
|
|
898
|
+
}
|
|
899
|
+
const authorAgent = _normalizeAgent(input.authorAgent, 'lesson author agent');
|
|
900
|
+
const audience = String(input.audience || '').trim().toLowerCase();
|
|
901
|
+
if (!AUDIENCES.has(audience)) throw new TypeError(`invalid review-learning audience: ${input.audience}`);
|
|
902
|
+
const rules = _normalizeRules(input);
|
|
903
|
+
const facets = normalizeReviewFacets(input);
|
|
904
|
+
const area = input.area ? normalizeReviewArea(input.area) : facets.area;
|
|
905
|
+
const evidence = _normalizeEvidence(input.evidence);
|
|
906
|
+
const sourceKey = findingKey.split('|')[2];
|
|
907
|
+
const confidence = Number.isFinite(Number(input.confidence))
|
|
908
|
+
? Math.max(0, Math.min(1, Number(input.confidence)))
|
|
909
|
+
: 0.8;
|
|
910
|
+
const metadata = _buildLessonMetadata({
|
|
911
|
+
...(input.metadata || {}),
|
|
912
|
+
kind: 'review-learning-lesson',
|
|
913
|
+
findingKey,
|
|
914
|
+
prId,
|
|
915
|
+
sourceKey,
|
|
916
|
+
authorAgent,
|
|
917
|
+
audience,
|
|
918
|
+
disposition,
|
|
919
|
+
area,
|
|
920
|
+
}, {
|
|
921
|
+
ruleHashes: rules.map(_ruleHash),
|
|
922
|
+
files: facets.files,
|
|
923
|
+
symbols: facets.symbols,
|
|
924
|
+
tags: facets.tags,
|
|
925
|
+
evidence,
|
|
926
|
+
});
|
|
927
|
+
const body = redactBoundedText(
|
|
928
|
+
`Rules:\n${rules.map(rule => `- ${rule}`).join('\n')}`,
|
|
929
|
+
REVIEW_LEARNING_BOUNDS.bodyBytes,
|
|
930
|
+
'review-learning lesson body',
|
|
931
|
+
);
|
|
932
|
+
const title = redactBoundedText(
|
|
933
|
+
`Review lesson: ${rules[0]}`,
|
|
934
|
+
REVIEW_LEARNING_BOUNDS.titleBytes,
|
|
935
|
+
'review-learning lesson title',
|
|
936
|
+
);
|
|
937
|
+
return {
|
|
938
|
+
memoryType: 'procedural',
|
|
939
|
+
scopeType: 'project',
|
|
940
|
+
scopeKey: project,
|
|
941
|
+
title,
|
|
942
|
+
body,
|
|
943
|
+
tags: ['review-learning', 'procedural', ...facets.tags],
|
|
944
|
+
sourceType: REVIEW_LEARNING_SOURCE_TYPE,
|
|
945
|
+
sourceRef: findingKey,
|
|
946
|
+
trust: 'system',
|
|
947
|
+
importance: Number.isFinite(Number(input.importance))
|
|
948
|
+
? Math.max(0, Math.min(1, Number(input.importance)))
|
|
949
|
+
: 0.8,
|
|
950
|
+
confidence,
|
|
951
|
+
status: 'active',
|
|
952
|
+
metadata,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function _lessonRuleHashes(record) {
|
|
957
|
+
const metadataHashes = Array.isArray(record?.metadata?.ruleHashes)
|
|
958
|
+
? record.metadata.ruleHashes.filter(value => /^sha256:[0-9a-f]{64}$/.test(value))
|
|
959
|
+
: [];
|
|
960
|
+
const bodyRules = metadataHashes.length === 0
|
|
961
|
+
? String(record?.body || '')
|
|
962
|
+
.split(/\r?\n/)
|
|
963
|
+
.map(line => line.match(/^\s*-\s+(.+)$/)?.[1] || '')
|
|
964
|
+
.filter(Boolean)
|
|
965
|
+
: [];
|
|
966
|
+
return [...new Set([
|
|
967
|
+
...metadataHashes,
|
|
968
|
+
...bodyRules.map(rule => _ruleHash(redactBoundedText(
|
|
969
|
+
rule,
|
|
970
|
+
REVIEW_LEARNING_BOUNDS.ruleBytes,
|
|
971
|
+
'review-learning match rule',
|
|
972
|
+
))),
|
|
973
|
+
])].sort();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function findDeterministicRepeatedLessonMatch(input = {}, records = []) {
|
|
977
|
+
const current = normalizeReviewFacets(input);
|
|
978
|
+
const currentRuleHashes = _normalizeRules(input).map(_ruleHash);
|
|
979
|
+
const matches = [];
|
|
980
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
981
|
+
const lessonId = String(record?.id || '').trim();
|
|
982
|
+
if (!lessonId || record?.status !== 'active') continue;
|
|
983
|
+
if (record?.sourceType !== REVIEW_LEARNING_SOURCE_TYPE) continue;
|
|
984
|
+
if (record?.metadata?.kind !== 'review-learning-lesson') continue;
|
|
985
|
+
const prior = normalizeReviewFacets(record.metadata);
|
|
986
|
+
const exactSymbol = current.symbols.find(symbol => prior.symbols.includes(symbol));
|
|
987
|
+
if (exactSymbol) {
|
|
988
|
+
matches.push({ lessonId, reason: `exact-symbol:${exactSymbol}`, rank: 0 });
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
const exactFile = current.files.find(file => prior.files.includes(file));
|
|
992
|
+
if (!exactFile) continue;
|
|
993
|
+
const priorRuleHashes = _lessonRuleHashes(record);
|
|
994
|
+
const exactRuleHash = currentRuleHashes.find(ruleHash => priorRuleHashes.includes(ruleHash));
|
|
995
|
+
if (exactRuleHash) {
|
|
996
|
+
matches.push({
|
|
997
|
+
lessonId,
|
|
998
|
+
reason: `exact-file-rule:${exactFile}:${exactRuleHash.slice(0, 23)}`,
|
|
999
|
+
rank: 1,
|
|
1000
|
+
});
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
const exactTag = current.tags.find(tag => prior.tags.includes(tag));
|
|
1004
|
+
if (exactTag) {
|
|
1005
|
+
matches.push({ lessonId, reason: `exact-file-tag:${exactFile}:${exactTag}`, rank: 2 });
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
matches.sort((a, b) => a.rank - b.rank
|
|
1009
|
+
|| a.lessonId.localeCompare(b.lessonId)
|
|
1010
|
+
|| a.reason.localeCompare(b.reason));
|
|
1011
|
+
if (!matches.length) return null;
|
|
1012
|
+
const { lessonId, reason } = matches[0];
|
|
1013
|
+
return { lessonId, reason };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function candidateTransitionFor(disposition) {
|
|
1017
|
+
const normalized = normalizeDisposition(disposition);
|
|
1018
|
+
return { disposition: normalized, ...CANDIDATE_TRANSITIONS[normalized] };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function normalizeSelectedIds(values) {
|
|
1022
|
+
if (values == null) return [];
|
|
1023
|
+
if (!Array.isArray(values)) throw new TypeError('retrieval selectedIds must be an array');
|
|
1024
|
+
const selected = [];
|
|
1025
|
+
const seen = new Set();
|
|
1026
|
+
for (const value of values) {
|
|
1027
|
+
const id = redactBoundedText(value, REVIEW_LEARNING_BOUNDS.selectedIdBytes, 'selected memory id')
|
|
1028
|
+
.replace(/\s+/g, '-');
|
|
1029
|
+
if (!id || seen.has(id)) continue;
|
|
1030
|
+
seen.add(id);
|
|
1031
|
+
selected.push(id);
|
|
1032
|
+
if (selected.length >= REVIEW_LEARNING_BOUNDS.selectedIds) break;
|
|
1033
|
+
}
|
|
1034
|
+
while (_byteLength(JSON.stringify(selected)) > REVIEW_LEARNING_BOUNDS.selectedIdsBytes) selected.pop();
|
|
1035
|
+
return selected;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function _normalizeReason(value) {
|
|
1039
|
+
return redactBoundedText(value, REVIEW_LEARNING_BOUNDS.decisionReasonBytes, 'retrieval reason')
|
|
1040
|
+
.toLowerCase()
|
|
1041
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
1042
|
+
.replace(/^-+|-+$/g, '');
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function normalizeDecisionTrace(values) {
|
|
1046
|
+
if (values == null) return [];
|
|
1047
|
+
if (!Array.isArray(values)) throw new TypeError('retrieval decisionTrace must be an array');
|
|
1048
|
+
const trace = [];
|
|
1049
|
+
for (const raw of values.slice(0, REVIEW_LEARNING_BOUNDS.decisionTraceEntries)) {
|
|
1050
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1051
|
+
throw new TypeError('retrieval decision entries must be objects');
|
|
1052
|
+
}
|
|
1053
|
+
assertNoProhibitedReviewPayload(raw);
|
|
1054
|
+
for (const key of Object.keys(raw)) {
|
|
1055
|
+
if (!DECISION_KEYS.has(key)) throw new TypeError(`unsupported retrieval decision field: ${key}`);
|
|
1056
|
+
}
|
|
1057
|
+
const entry = {};
|
|
1058
|
+
if (raw.id != null) {
|
|
1059
|
+
entry.id = redactBoundedText(raw.id, REVIEW_LEARNING_BOUNDS.selectedIdBytes, 'decision memory id')
|
|
1060
|
+
.replace(/\s+/g, '-');
|
|
1061
|
+
}
|
|
1062
|
+
if (typeof raw.selected === 'boolean') entry.selected = raw.selected;
|
|
1063
|
+
if (Number.isInteger(Number(raw.rank)) && Number(raw.rank) >= 0) entry.rank = Number(raw.rank);
|
|
1064
|
+
if (Number.isFinite(Number(raw.score))) entry.score = Number(raw.score);
|
|
1065
|
+
const outcome = raw.outcome ?? raw.decision;
|
|
1066
|
+
if (outcome != null) entry.outcome = _normalizeReason(outcome);
|
|
1067
|
+
const reasons = raw.reasons ?? (raw.reason == null ? [] : [raw.reason]);
|
|
1068
|
+
if (!Array.isArray(reasons)) throw new TypeError('retrieval decision reasons must be an array');
|
|
1069
|
+
entry.reasons = [...new Set(reasons
|
|
1070
|
+
.slice(0, REVIEW_LEARNING_BOUNDS.decisionReasons)
|
|
1071
|
+
.map(_normalizeReason)
|
|
1072
|
+
.filter(Boolean))];
|
|
1073
|
+
trace.push(entry);
|
|
1074
|
+
}
|
|
1075
|
+
while (_byteLength(JSON.stringify(trace)) > REVIEW_LEARNING_BOUNDS.decisionTraceBytes) trace.pop();
|
|
1076
|
+
return trace;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function normalizeRetrievalKind(value) {
|
|
1080
|
+
const kind = String(value || 'general').trim().toLowerCase();
|
|
1081
|
+
return _boundedIdentifier(kind, 64, 'retrieval kind', /^[a-z][a-z0-9-]*$/);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ── Diagnostics filter normalizers (P-1ed2fb5f) ────────────────────────────
|
|
1085
|
+
// Each rejects malformed input with an actionable TypeError so the dashboard
|
|
1086
|
+
// API can surface a 400 rather than silently ignoring an invalid filter.
|
|
1087
|
+
function normalizeAudienceFilter(value) {
|
|
1088
|
+
const audience = String(value || '').trim().toLowerCase();
|
|
1089
|
+
if (!AUDIENCES.has(audience)) {
|
|
1090
|
+
throw new TypeError(
|
|
1091
|
+
`invalid review-learning audience: ${value} (expected ${[...AUDIENCES].join(' or ')})`,
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
return audience;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function normalizeSourceTypeFilter(value) {
|
|
1098
|
+
const sourceType = String(value || '').trim().toLowerCase();
|
|
1099
|
+
return _boundedIdentifier(sourceType, 64, 'memory source type', /^[a-z][a-z0-9-]*$/);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function normalizeAgentFilter(value) {
|
|
1103
|
+
const agent = String(value || '').trim().toLowerCase();
|
|
1104
|
+
return _boundedIdentifier(agent, 96, 'agent filter', /^[a-z0-9][a-z0-9._-]*$/);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function normalizeEventTypeFilter(value) {
|
|
1108
|
+
return normalizeLifecycleEventType(value);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function normalizeLifecycleEventType(value) {
|
|
1112
|
+
const eventType = String(value || '').trim().toLowerCase();
|
|
1113
|
+
if (!EVENT_TYPES.has(eventType)) throw new TypeError(`invalid review-learning event type: ${value}`);
|
|
1114
|
+
return eventType;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function buildLifecycleEventKey(event = {}) {
|
|
1118
|
+
const identity = [
|
|
1119
|
+
event.eventType,
|
|
1120
|
+
event.memoryId,
|
|
1121
|
+
event.findingKey,
|
|
1122
|
+
event.project,
|
|
1123
|
+
event.agent,
|
|
1124
|
+
event.area,
|
|
1125
|
+
event.disposition,
|
|
1126
|
+
event.workItemId,
|
|
1127
|
+
event.dispatchId,
|
|
1128
|
+
event.identity,
|
|
1129
|
+
].map(value => String(value == null ? '' : value).trim().toLowerCase());
|
|
1130
|
+
const eventKey = `rl-event-${crypto.createHash('sha256').update(JSON.stringify(identity)).digest('hex').slice(0, 40)}`;
|
|
1131
|
+
if (_byteLength(eventKey) > REVIEW_LEARNING_BOUNDS.eventKeyBytes) {
|
|
1132
|
+
throw new TypeError(`event key exceeds ${REVIEW_LEARNING_BOUNDS.eventKeyBytes} bytes`);
|
|
1133
|
+
}
|
|
1134
|
+
return eventKey;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function normalizeLifecycleEvent(input = {}) {
|
|
1138
|
+
assertNoProhibitedReviewPayload(input);
|
|
1139
|
+
const eventType = normalizeLifecycleEventType(input.eventType);
|
|
1140
|
+
const project = normalizeProjectKey(input.project);
|
|
1141
|
+
const memoryId = _optionalIdentifier(input.memoryId, REVIEW_LEARNING_BOUNDS.refBytes, 'event memory id');
|
|
1142
|
+
let findingKey = null;
|
|
1143
|
+
if (input.findingKey != null && String(input.findingKey).trim() !== '') {
|
|
1144
|
+
const rawFindingKey = String(input.findingKey).trim();
|
|
1145
|
+
findingKey = rawFindingKey.includes('|')
|
|
1146
|
+
? normalizeFindingKey(rawFindingKey)
|
|
1147
|
+
: _boundedIdentifier(
|
|
1148
|
+
rawFindingKey,
|
|
1149
|
+
REVIEW_LEARNING_BOUNDS.findingKeyBytes,
|
|
1150
|
+
'event finding key',
|
|
1151
|
+
);
|
|
1152
|
+
const findingProject = findingKey.includes('|') ? findingKey.split('|', 1)[0] : project;
|
|
1153
|
+
if (findingProject !== project) {
|
|
1154
|
+
throw new TypeError(`finding key project ${findingProject} does not match event scope ${project}`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (!memoryId && !findingKey) throw new TypeError('lifecycle event requires memoryId or findingKey');
|
|
1158
|
+
const disposition = input.disposition == null || String(input.disposition).trim() === ''
|
|
1159
|
+
? null
|
|
1160
|
+
: normalizeDisposition(input.disposition);
|
|
1161
|
+
const normalized = {
|
|
1162
|
+
eventType,
|
|
1163
|
+
memoryId,
|
|
1164
|
+
findingKey,
|
|
1165
|
+
project,
|
|
1166
|
+
agent: _optionalIdentifier(input.agent, 96, 'event agent'),
|
|
1167
|
+
area: normalizeReviewArea(input.area || 'general'),
|
|
1168
|
+
disposition,
|
|
1169
|
+
workItemId: _optionalIdentifier(input.workItemId, REVIEW_LEARNING_BOUNDS.refBytes, 'event work item id'),
|
|
1170
|
+
dispatchId: _optionalIdentifier(input.dispatchId, REVIEW_LEARNING_BOUNDS.refBytes, 'event dispatch id'),
|
|
1171
|
+
identity: _optionalIdentifier(input.identity, REVIEW_LEARNING_BOUNDS.refBytes, 'event identity'),
|
|
1172
|
+
metadata: normalizeReviewMetadata(input.metadata || {}),
|
|
1173
|
+
createdAt: Number.isFinite(Number(input.createdAt)) ? Number(input.createdAt) : Date.now(),
|
|
1174
|
+
};
|
|
1175
|
+
normalized.eventKey = buildLifecycleEventKey(normalized);
|
|
1176
|
+
return normalized;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function upsertReviewLearningCandidate(input) {
|
|
1180
|
+
return require('./memory-store').upsertReviewLearningCandidate(input);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function upsertReviewLearningLesson(input) {
|
|
1184
|
+
return require('./memory-store').upsertReviewLearningLesson(input);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function transitionReviewLearningCandidate(findingKey, disposition, options) {
|
|
1188
|
+
return require('./memory-store').transitionReviewLearningCandidate(findingKey, disposition, options);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
module.exports = {
|
|
1192
|
+
REVIEW_LEARNING_SOURCE_TYPE,
|
|
1193
|
+
REVIEW_LEARNING_DISPOSITIONS,
|
|
1194
|
+
REVIEW_LEARNING_EVENT_TYPES,
|
|
1195
|
+
REVIEW_LEARNING_APPLICATION_OUTCOMES,
|
|
1196
|
+
REVIEW_LEARNING_HUMAN_ACTION_EVENTS,
|
|
1197
|
+
REVIEW_LEARNING_BOUNDS,
|
|
1198
|
+
POSITIVE_DISPOSITIONS,
|
|
1199
|
+
CANDIDATE_TRANSITIONS,
|
|
1200
|
+
normalizeDisposition,
|
|
1201
|
+
normalizeProjectKey,
|
|
1202
|
+
normalizeCanonicalPrId,
|
|
1203
|
+
normalizeFindingSourceKey,
|
|
1204
|
+
hashReviewCommentContent,
|
|
1205
|
+
buildReviewCommentSource,
|
|
1206
|
+
normalizeReviewCommentSources,
|
|
1207
|
+
selectReviewCommentSourceSnapshot,
|
|
1208
|
+
migrateLegacyReviewCommentEdits,
|
|
1209
|
+
buildFindingKey,
|
|
1210
|
+
normalizeFindingKey,
|
|
1211
|
+
normalizeReviewArea,
|
|
1212
|
+
normalizeReviewFacets,
|
|
1213
|
+
redactBoundedText,
|
|
1214
|
+
assertNoProhibitedReviewPayload,
|
|
1215
|
+
normalizeReviewMetadata,
|
|
1216
|
+
classifyExactEvidence,
|
|
1217
|
+
normalizeCompletionReviewLearnings,
|
|
1218
|
+
normalizeCompletionReviewLearningApplications,
|
|
1219
|
+
buildCandidateMemoryRecord,
|
|
1220
|
+
buildLessonMemoryRecord,
|
|
1221
|
+
findDeterministicRepeatedLessonMatch,
|
|
1222
|
+
candidateTransitionFor,
|
|
1223
|
+
normalizeSelectedIds,
|
|
1224
|
+
normalizeDecisionTrace,
|
|
1225
|
+
normalizeRetrievalKind,
|
|
1226
|
+
normalizeAudienceFilter,
|
|
1227
|
+
normalizeSourceTypeFilter,
|
|
1228
|
+
normalizeAgentFilter,
|
|
1229
|
+
normalizeEventTypeFilter,
|
|
1230
|
+
normalizeLifecycleEventType,
|
|
1231
|
+
buildLifecycleEventKey,
|
|
1232
|
+
normalizeLifecycleEvent,
|
|
1233
|
+
upsertReviewLearningCandidate,
|
|
1234
|
+
upsertReviewLearningLesson,
|
|
1235
|
+
transitionReviewLearningCandidate,
|
|
1236
|
+
};
|