@evo-dev/core 0.0.1-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/assets/agents/review/code-reviewer/examples.md +19 -0
  2. package/assets/agents/review/code-reviewer/manifest.json +10 -0
  3. package/assets/agents/review/code-reviewer/prompt.md +59 -0
  4. package/assets/agents/review/code-reviewer/verification.md +11 -0
  5. package/assets/skills/coding/engineering-discipline/SKILL.md +63 -0
  6. package/assets/skills/coding/engineering-discipline/anti-patterns.md +21 -0
  7. package/assets/skills/coding/engineering-discipline/examples.md +19 -0
  8. package/assets/skills/coding/engineering-discipline/manifest.json +10 -0
  9. package/assets/skills/coding/engineering-discipline/verification.md +11 -0
  10. package/assets/workflows/rd-bug-fix/WORKFLOW.json +45 -0
  11. package/assets/workflows/rd-code-review/WORKFLOW.json +45 -0
  12. package/assets/workflows/rd-docs-update/WORKFLOW.json +45 -0
  13. package/assets/workflows/rd-feature-implementation/WORKFLOW.json +45 -0
  14. package/assets/workflows/rd-refactor/WORKFLOW.json +45 -0
  15. package/assets/workflows/rd-release-readiness/WORKFLOW.json +49 -0
  16. package/assets/workflows/rd-security-boundary-review/WORKFLOW.json +45 -0
  17. package/assets/workflows/rd-test-generation/WORKFLOW.json +45 -0
  18. package/dist/assets/index.js +209 -0
  19. package/dist/config/index.js +601 -0
  20. package/dist/index.js +4879 -0
  21. package/dist/plugins/index.js +265 -0
  22. package/package.json +30 -0
  23. package/src/.gitkeep +0 -0
  24. package/src/agents/index.ts +561 -0
  25. package/src/assets/errors.ts +21 -0
  26. package/src/assets/index.ts +18 -0
  27. package/src/assets/manifest.ts +109 -0
  28. package/src/assets/scanner.ts +189 -0
  29. package/src/config/errors.ts +21 -0
  30. package/src/config/index.ts +26 -0
  31. package/src/config/paths.ts +43 -0
  32. package/src/config/registry.ts +84 -0
  33. package/src/config/settings.ts +212 -0
  34. package/src/config/state.ts +130 -0
  35. package/src/config/store.ts +166 -0
  36. package/src/daemon/index.ts +414 -0
  37. package/src/hooks/index.ts +1023 -0
  38. package/src/index.ts +14 -0
  39. package/src/learning/index.ts +714 -0
  40. package/src/observability/index.ts +272 -0
  41. package/src/pack/index.ts +779 -0
  42. package/src/plugins/capabilities.ts +347 -0
  43. package/src/plugins/index.ts +41 -0
  44. package/src/plugins/registry.ts +60 -0
  45. package/src/plugins/types.ts +123 -0
  46. package/src/project/index.ts +507 -0
  47. package/src/protected-zones/index.ts +137 -0
  48. package/src/sync/index.ts +7 -0
  49. package/src/sync/orchestrator.ts +298 -0
  50. package/src/task/index.ts +840 -0
  51. package/src/workflow/index.ts +137 -0
@@ -0,0 +1,714 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export type LearningCandidateStatus = "candidate" | "rejected" | "deferred";
5
+ export type LearningCandidateKind =
6
+ | "lesson"
7
+ | "anti-criteria"
8
+ | "workflow-improvement"
9
+ | "skill-improvement";
10
+ export type LearningCandidateScopeLevel = "user" | "project" | "workflow" | "skill" | "asset";
11
+ export type LearningCandidateConfidence = "low" | "medium" | "high";
12
+ export type LearningReviewDecision = "rejected" | "deferred";
13
+
14
+ export interface LearningCandidate {
15
+ version: 1;
16
+ id: string;
17
+ kind: LearningCandidateKind;
18
+ status: LearningCandidateStatus;
19
+ routingInfluence: false;
20
+ scope: {
21
+ level: LearningCandidateScopeLevel;
22
+ projectId: string | null;
23
+ workflowId: string | null;
24
+ skillId: string | null;
25
+ assetId?: string | null;
26
+ };
27
+ content: {
28
+ summary: string;
29
+ howToApply: string;
30
+ antiCriteriaImpact: string[];
31
+ };
32
+ provenance: {
33
+ taskId: string | null;
34
+ taskContractRef: string | null;
35
+ workflowRunId: string | null;
36
+ evidenceRefs: string[];
37
+ sourceType:
38
+ | "task-close"
39
+ | "verification-failure"
40
+ | "review-finding"
41
+ | "user-feedback"
42
+ | "manual-candidate";
43
+ createdAt: string;
44
+ createdBy: "evodev" | "user";
45
+ rawPromptStored: false;
46
+ sourceContentStored: false;
47
+ rawCommandOutputStored: false;
48
+ };
49
+ privacy: {
50
+ classification: "local-private";
51
+ containsSource: false;
52
+ containsSecrets: false;
53
+ containsInternalLinks: false;
54
+ containsPersonalData: false;
55
+ };
56
+ review: {
57
+ decision: "pending" | "rejected" | "deferred";
58
+ reviewedAt: string | null;
59
+ reviewedBy: string | null;
60
+ reason?: string;
61
+ };
62
+ retention: {
63
+ staleAfter: string | null;
64
+ deleteAllowed: true;
65
+ exportAllowed: true;
66
+ };
67
+ confidence: LearningCandidateConfidence;
68
+ }
69
+
70
+ export interface LearningReviewDecisionRecord {
71
+ version: 1;
72
+ candidateId: string;
73
+ decision: LearningReviewDecision;
74
+ decidedAt: string;
75
+ decidedBy: "user";
76
+ reason: string | null;
77
+ writesAcceptedMemory: false;
78
+ affectsRouting: false;
79
+ candidateStatus: "rejected" | "deferred";
80
+ }
81
+
82
+ export interface LearningLintFinding {
83
+ candidateId: string;
84
+ severity: "error" | "warning";
85
+ field: string;
86
+ message: string;
87
+ }
88
+
89
+ export interface LearningLintResult {
90
+ ok: boolean;
91
+ candidatesChecked: number;
92
+ decisionsChecked: number;
93
+ findings: LearningLintFinding[];
94
+ }
95
+
96
+ export interface LearningReviewEntry {
97
+ candidate: LearningCandidate;
98
+ decision: "pending" | "deferred" | "rejected";
99
+ decisionRecord: LearningReviewDecisionRecord | null;
100
+ stale: boolean;
101
+ }
102
+
103
+ export interface LearningReviewOptions {
104
+ includeRejected?: boolean;
105
+ now?: string;
106
+ }
107
+
108
+ export interface LearningLintOptions {
109
+ decisions?: LearningReviewDecisionRecord[];
110
+ now?: string;
111
+ }
112
+
113
+ const LEARNING_CANDIDATE_KINDS = [
114
+ "lesson",
115
+ "anti-criteria",
116
+ "workflow-improvement",
117
+ "skill-improvement",
118
+ ] as const;
119
+ const LEARNING_CANDIDATE_STATUSES = ["candidate", "rejected", "deferred"] as const;
120
+ const LEARNING_SCOPE_LEVELS = ["user", "project", "workflow", "skill", "asset"] as const;
121
+ const LEARNING_SOURCE_TYPES = [
122
+ "task-close",
123
+ "verification-failure",
124
+ "review-finding",
125
+ "user-feedback",
126
+ "manual-candidate",
127
+ ] as const;
128
+ const LEARNING_CREATED_BY_VALUES = ["evodev", "user"] as const;
129
+ const LEARNING_REVIEW_DECISIONS = ["pending", "rejected", "deferred"] as const;
130
+ const LEARNING_CONFIDENCE_VALUES = ["low", "medium", "high"] as const;
131
+ const LEARNING_DECISION_VALUES = ["rejected", "deferred"] as const;
132
+ const LEARNING_DECIDED_BY_VALUES = ["user"] as const;
133
+ const LEARNING_CANDIDATE_DECISION_STATUSES = ["rejected", "deferred"] as const;
134
+
135
+ const FORBIDDEN_RAW_KEYS = new Set([
136
+ "commandhistory",
137
+ "commandoutput",
138
+ "credential",
139
+ "credentials",
140
+ "env",
141
+ "internalurl",
142
+ "memorybody",
143
+ "memorybodies",
144
+ "password",
145
+ "privatekey",
146
+ "prompt",
147
+ "promptbody",
148
+ "prompttext",
149
+ "rawcommand",
150
+ "rawcommandoutput",
151
+ "rawlog",
152
+ "rawlogs",
153
+ "rawoutput",
154
+ "rawpayload",
155
+ "rawprompt",
156
+ "secret",
157
+ "secretvalue",
158
+ "source",
159
+ "sourcebody",
160
+ "sourcecode",
161
+ "sourcecontent",
162
+ "sourcetext",
163
+ "stderr",
164
+ "stdout",
165
+ "token",
166
+ "transcript",
167
+ "transcriptbody",
168
+ "transcripttext",
169
+ ]);
170
+ const SENSITIVE_TEXT_PATTERN =
171
+ /https?:\/\/\S+|\b(secret|token|password|passwd|private|internal|api[_-]?key|apikey|credential|credentials|raw log|raw logs|raw output|raw source|raw prompt|shell history|command history)\b/i;
172
+ const PROTECTED_PATH_PATTERN =
173
+ /(^|[~/\\])(?:USER|KNOWLEDGE|LEARNING|OBSERVABILITY|PACKS|RELEASES|logs?|memory|\.env[^/\\]*)(?:$|[/\\])|PROJECTS[/\\][^/\\]+[/\\]LEARNING(?:$|[/\\])|\.evodev[/\\](?:USER|KNOWLEDGE|LEARNING|OBSERVABILITY|PACKS|RELEASES)(?:$|[/\\])/i;
174
+
175
+ export function createLearningCandidate(input: {
176
+ id: string;
177
+ kind: LearningCandidateKind;
178
+ scope: LearningCandidate["scope"];
179
+ content: LearningCandidate["content"];
180
+ provenance: Omit<
181
+ LearningCandidate["provenance"],
182
+ "rawPromptStored" | "sourceContentStored" | "rawCommandOutputStored"
183
+ >;
184
+ retention?: Partial<LearningCandidate["retention"]>;
185
+ confidence?: LearningCandidateConfidence;
186
+ }): LearningCandidate {
187
+ const candidate: LearningCandidate = {
188
+ version: 1,
189
+ id: sanitizeId(input.id),
190
+ kind: input.kind,
191
+ status: "candidate",
192
+ routingInfluence: false,
193
+ scope: sanitizeScope(input.scope),
194
+ content: {
195
+ summary: sanitizeText(input.content.summary),
196
+ howToApply: sanitizeText(input.content.howToApply),
197
+ antiCriteriaImpact: input.content.antiCriteriaImpact.map(sanitizeText),
198
+ },
199
+ provenance: {
200
+ taskId: sanitizeNullableId(input.provenance.taskId),
201
+ taskContractRef: sanitizeNullableText(input.provenance.taskContractRef),
202
+ workflowRunId: sanitizeNullableId(input.provenance.workflowRunId),
203
+ evidenceRefs: input.provenance.evidenceRefs.map(sanitizeText),
204
+ sourceType: input.provenance.sourceType,
205
+ createdAt: sanitizeText(input.provenance.createdAt),
206
+ createdBy: input.provenance.createdBy,
207
+ rawPromptStored: false,
208
+ sourceContentStored: false,
209
+ rawCommandOutputStored: false,
210
+ },
211
+ privacy: createSafePrivacy(),
212
+ review: { decision: "pending", reviewedAt: null, reviewedBy: null },
213
+ retention: {
214
+ staleAfter:
215
+ input.retention?.staleAfter === undefined
216
+ ? null
217
+ : sanitizeNullableText(input.retention.staleAfter),
218
+ deleteAllowed: true,
219
+ exportAllowed: true,
220
+ },
221
+ confidence: input.confidence ?? "medium",
222
+ };
223
+ validateLearningCandidate(candidate);
224
+ return candidate;
225
+ }
226
+
227
+ export function validateLearningCandidate(candidate: LearningCandidate): void {
228
+ if (!isRecord(candidate)) throw new Error("Learning candidate must be an object.");
229
+ if (candidate.version !== 1) throw new Error("Learning candidate version must be 1.");
230
+ assertEnumValue("kind", candidate.kind, LEARNING_CANDIDATE_KINDS);
231
+ assertEnumValue("status", candidate.status, LEARNING_CANDIDATE_STATUSES);
232
+ if (candidate.status !== "candidate") {
233
+ throw new Error("Learning review queue accepts candidate status only.");
234
+ }
235
+ if (candidate.routingInfluence !== false) {
236
+ throw new Error("Learning candidate routingInfluence must be false in I5.");
237
+ }
238
+ if (!isRecord(candidate.scope)) throw new Error("Learning candidate scope must be an object.");
239
+ assertEnumValue("scope.level", candidate.scope.level, LEARNING_SCOPE_LEVELS);
240
+ if (!isRecord(candidate.content)) {
241
+ throw new Error("Learning candidate content must be an object.");
242
+ }
243
+ assertStringField("content.summary", candidate.content.summary);
244
+ assertStringField("content.howToApply", candidate.content.howToApply);
245
+ assertStringArrayField("content.antiCriteriaImpact", candidate.content.antiCriteriaImpact);
246
+ if (!isRecord(candidate.provenance)) {
247
+ throw new Error("Learning candidate provenance must be an object.");
248
+ }
249
+ assertEnumValue("provenance.sourceType", candidate.provenance.sourceType, LEARNING_SOURCE_TYPES);
250
+ assertEnumValue(
251
+ "provenance.createdBy",
252
+ candidate.provenance.createdBy,
253
+ LEARNING_CREATED_BY_VALUES,
254
+ );
255
+ assertStringField("provenance.createdAt", candidate.provenance.createdAt);
256
+ assertStringArrayField("provenance.evidenceRefs", candidate.provenance.evidenceRefs);
257
+ if (!isRecord(candidate.privacy)) {
258
+ throw new Error("Learning candidate privacy must be an object.");
259
+ }
260
+ assertEnumValue("privacy.classification", candidate.privacy.classification, ["local-private"]);
261
+ if (!isRecord(candidate.review)) throw new Error("Learning candidate review must be an object.");
262
+ assertEnumValue("review.decision", candidate.review.decision, LEARNING_REVIEW_DECISIONS);
263
+ if (candidate.review.decision !== "pending") {
264
+ throw new Error("Learning candidate review decision must be pending.");
265
+ }
266
+ if (!isRecord(candidate.retention)) {
267
+ throw new Error("Learning candidate retention must be an object.");
268
+ }
269
+ assertEnumValue("confidence", candidate.confidence, LEARNING_CONFIDENCE_VALUES);
270
+ if (
271
+ candidate.provenance.rawPromptStored !== false ||
272
+ candidate.provenance.sourceContentStored !== false ||
273
+ candidate.provenance.rawCommandOutputStored !== false
274
+ ) {
275
+ throw new Error("Learning candidate cannot store raw prompt/source/command output.");
276
+ }
277
+ if (
278
+ candidate.privacy.containsSource !== false ||
279
+ candidate.privacy.containsSecrets !== false ||
280
+ candidate.privacy.containsInternalLinks !== false ||
281
+ candidate.privacy.containsPersonalData !== false
282
+ ) {
283
+ throw new Error(
284
+ "Learning candidate privacy fields must be local-private and raw-content-free.",
285
+ );
286
+ }
287
+ if (candidate.retention.deleteAllowed !== true || candidate.retention.exportAllowed !== true) {
288
+ throw new Error("Learning candidate retention must allow delete and export.");
289
+ }
290
+ assertNoForbiddenContent(candidate);
291
+ }
292
+
293
+ export function validateLearningReviewDecisionRecord(record: LearningReviewDecisionRecord): void {
294
+ if (!isRecord(record)) throw new Error("Learning review decision must be an object.");
295
+ if (record.version !== 1) throw new Error("Learning review decision version must be 1.");
296
+ assertStringField("candidateId", record.candidateId);
297
+ assertEnumValue("decision", record.decision, LEARNING_DECISION_VALUES);
298
+ assertStringField("decidedAt", record.decidedAt);
299
+ assertEnumValue("decidedBy", record.decidedBy, LEARNING_DECIDED_BY_VALUES);
300
+ if (record.reason !== null) assertStringField("reason", record.reason);
301
+ if (record.writesAcceptedMemory !== false) {
302
+ throw new Error("Learning review decision writesAcceptedMemory must be false in I5.");
303
+ }
304
+ if (record.affectsRouting !== false) {
305
+ throw new Error("Learning review decision affectsRouting must be false in I5.");
306
+ }
307
+ assertEnumValue("candidateStatus", record.candidateStatus, LEARNING_CANDIDATE_DECISION_STATUSES);
308
+ if (record.candidateStatus !== record.decision) {
309
+ throw new Error("Learning review decision candidateStatus must match decision.");
310
+ }
311
+ assertNoForbiddenContent(record);
312
+ }
313
+
314
+ export function parseLearningCandidate(value: unknown): LearningCandidate {
315
+ if (!isRecord(value)) throw new Error("Invalid learning candidate JSON.");
316
+ validateLearningCandidate(value as unknown as LearningCandidate);
317
+ return value as unknown as LearningCandidate;
318
+ }
319
+
320
+ export function parseLearningReviewDecisionRecord(value: unknown): LearningReviewDecisionRecord {
321
+ if (!isRecord(value)) throw new Error("Invalid learning review decision JSON.");
322
+ validateLearningReviewDecisionRecord(value as unknown as LearningReviewDecisionRecord);
323
+ return value as unknown as LearningReviewDecisionRecord;
324
+ }
325
+
326
+ export async function readLearningCandidates(path: string): Promise<LearningCandidate[]> {
327
+ return parseJsonOrJsonlFile(path, parseLearningCandidate);
328
+ }
329
+
330
+ export async function readLearningReviewDecisions(
331
+ path: string,
332
+ ): Promise<LearningReviewDecisionRecord[]> {
333
+ return parseJsonOrJsonlFile(path, parseLearningReviewDecisionRecord);
334
+ }
335
+
336
+ export function resolveLearningCandidateQueuePath(homeDir: string): string {
337
+ return join(homeDir, ".evodev", "STATE", "learning", "candidates.jsonl");
338
+ }
339
+
340
+ export function resolveLearningDecisionPath(homeDir: string): string {
341
+ return join(homeDir, ".evodev", "STATE", "learning", "review-decisions.jsonl");
342
+ }
343
+
344
+ export async function appendLearningCandidate(
345
+ homeDir: string,
346
+ candidate: LearningCandidate,
347
+ ): Promise<string> {
348
+ validateLearningCandidate(candidate);
349
+ const path = resolveLearningCandidateQueuePath(homeDir);
350
+ await mkdir(dirname(path), { recursive: true });
351
+ await writeFile(path, `${JSON.stringify(candidate)}\n`, { encoding: "utf8", flag: "a" });
352
+ return path;
353
+ }
354
+
355
+ export async function listLearningCandidates(homeDir: string): Promise<LearningCandidate[]> {
356
+ const path = resolveLearningCandidateQueuePath(homeDir);
357
+ if (!(await pathExists(path))) return [];
358
+ return readLearningCandidates(path);
359
+ }
360
+
361
+ export async function listLearningReviewDecisions(
362
+ homeDir: string,
363
+ ): Promise<LearningReviewDecisionRecord[]> {
364
+ const path = resolveLearningDecisionPath(homeDir);
365
+ if (!(await pathExists(path))) return [];
366
+ return readLearningReviewDecisions(path);
367
+ }
368
+
369
+ export function lintLearningCandidates(
370
+ candidates: LearningCandidate[],
371
+ options: LearningLintOptions = {},
372
+ ): LearningLintResult {
373
+ const findings: LearningLintFinding[] = [];
374
+
375
+ for (const candidate of candidates) {
376
+ try {
377
+ validateLearningCandidate(candidate);
378
+ } catch (error) {
379
+ findings.push({
380
+ candidateId: typeof candidate.id === "string" ? candidate.id : "unknown",
381
+ severity: "error",
382
+ field: "candidate",
383
+ message: error instanceof Error ? error.message : String(error),
384
+ });
385
+ continue;
386
+ }
387
+
388
+ for (const [field, value] of requiredFields(candidate)) {
389
+ if (value === undefined || value === null || value === "") {
390
+ findings.push({
391
+ candidateId: candidate.id,
392
+ severity: "error",
393
+ field,
394
+ message: "Required learning candidate field is missing.",
395
+ });
396
+ }
397
+ }
398
+
399
+ if (candidate.provenance.evidenceRefs.length === 0) {
400
+ findings.push({
401
+ candidateId: candidate.id,
402
+ severity: "error",
403
+ field: "provenance.evidenceRefs",
404
+ message: "Learning candidate must include at least one evidence ref.",
405
+ });
406
+ }
407
+
408
+ if (isCandidateStale(candidate, options.now)) {
409
+ findings.push({
410
+ candidateId: candidate.id,
411
+ severity: "error",
412
+ field: "retention.staleAfter",
413
+ message: "Learning candidate is stale and requires review before use.",
414
+ });
415
+ }
416
+
417
+ for (const [field, path] of candidatePathFields(candidate)) {
418
+ if (PROTECTED_PATH_PATTERN.test(path)) {
419
+ findings.push({
420
+ candidateId: candidate.id,
421
+ severity: "error",
422
+ field,
423
+ message: "Learning candidate references a protected/private path zone.",
424
+ });
425
+ }
426
+ }
427
+ }
428
+
429
+ for (const decision of options.decisions ?? []) {
430
+ try {
431
+ validateLearningReviewDecisionRecord(decision);
432
+ } catch (error) {
433
+ findings.push({
434
+ candidateId:
435
+ isRecord(decision) && typeof decision.candidateId === "string"
436
+ ? decision.candidateId
437
+ : "unknown",
438
+ severity: "error",
439
+ field: "decision",
440
+ message: error instanceof Error ? error.message : String(error),
441
+ });
442
+ }
443
+ }
444
+
445
+ return {
446
+ ok: findings.every((finding) => finding.severity !== "error"),
447
+ candidatesChecked: candidates.length,
448
+ decisionsChecked: options.decisions?.length ?? 0,
449
+ findings,
450
+ };
451
+ }
452
+
453
+ export function buildLearningReviewEntries(
454
+ candidates: LearningCandidate[],
455
+ decisions: LearningReviewDecisionRecord[] = [],
456
+ options: LearningReviewOptions = {},
457
+ ): LearningReviewEntry[] {
458
+ const latestDecision = new Map<string, LearningReviewDecisionRecord>();
459
+ for (const decision of decisions) {
460
+ validateLearningReviewDecisionRecord(decision);
461
+ latestDecision.set(decision.candidateId, decision);
462
+ }
463
+
464
+ return candidates
465
+ .map((candidate) => {
466
+ validateLearningCandidate(candidate);
467
+ const decisionRecord = latestDecision.get(candidate.id) ?? null;
468
+ const decision: LearningReviewEntry["decision"] = decisionRecord?.decision ?? "pending";
469
+ return {
470
+ candidate,
471
+ decision,
472
+ decisionRecord,
473
+ stale: isCandidateStale(candidate, options.now),
474
+ };
475
+ })
476
+ .filter((entry) => options.includeRejected === true || entry.decision !== "rejected");
477
+ }
478
+
479
+ export function formatLearningReview(
480
+ candidates: LearningCandidate[],
481
+ decisions: LearningReviewDecisionRecord[] = [],
482
+ options: LearningReviewOptions = {},
483
+ ): string {
484
+ const entries = buildLearningReviewEntries(candidates, decisions, options);
485
+ return [
486
+ "EvoDev learning review queue",
487
+ "",
488
+ "Preview only: candidates are not accepted memory; accepted memory write is not implemented in I5.",
489
+ "Pending/deferred candidates do not affect routing; routingInfluence must remain false.",
490
+ `Candidates shown: ${entries.length}`,
491
+ ...entries.flatMap((entry) => [
492
+ "",
493
+ `- ${entry.candidate.id} (${entry.candidate.kind}, ${entry.candidate.scope.level}, ${entry.candidate.confidence})`,
494
+ ` Candidate status: ${entry.candidate.status}; decision state: ${entry.decision}`,
495
+ ` routingInfluence: ${entry.candidate.routingInfluence}; future influence: none in I5`,
496
+ ` Stale: ${entry.stale ? "yes" : "no"}; staleAfter: ${entry.candidate.retention.staleAfter ?? "none"}`,
497
+ ` Retention: deleteAllowed=${entry.candidate.retention.deleteAllowed}; exportAllowed=${entry.candidate.retention.exportAllowed}`,
498
+ ` Summary: ${entry.candidate.content.summary}`,
499
+ ` How to apply: ${entry.candidate.content.howToApply}`,
500
+ ` Provenance: task=${entry.candidate.provenance.taskId ?? "none"}, workflow=${entry.candidate.provenance.workflowRunId ?? "none"}, evidence=${entry.candidate.provenance.evidenceRefs.length}`,
501
+ " Privacy: local-private; rawPrompt=false; sourceContent=false; rawCommandOutput=false; secrets=false; internalLinks=false",
502
+ ]),
503
+ ].join("\n");
504
+ }
505
+
506
+ export function formatLearningLint(result: LearningLintResult): string {
507
+ return [
508
+ "EvoDev learning lint",
509
+ "",
510
+ `Status: ${result.ok ? "PASS" : "FAIL"}`,
511
+ `Candidates checked: ${result.candidatesChecked}`,
512
+ `Decisions checked: ${result.decisionsChecked}`,
513
+ `Findings: ${result.findings.length}`,
514
+ ...result.findings.map(
515
+ (finding) =>
516
+ `- ${finding.severity.toUpperCase()} ${finding.candidateId} ${finding.field}: ${finding.message}`,
517
+ ),
518
+ ].join("\n");
519
+ }
520
+
521
+ export function createLearningReviewDecisionRecord(input: {
522
+ candidateId: string;
523
+ decision: LearningReviewDecision;
524
+ decidedAt?: string;
525
+ reason?: string | null;
526
+ }): LearningReviewDecisionRecord {
527
+ assertEnumValue("decision", input.decision, LEARNING_DECISION_VALUES);
528
+ const candidateStatus = input.decision === "rejected" ? "rejected" : "deferred";
529
+ const record: LearningReviewDecisionRecord = {
530
+ version: 1,
531
+ candidateId: sanitizeId(input.candidateId),
532
+ decision: input.decision,
533
+ decidedAt: sanitizeText(input.decidedAt ?? new Date().toISOString()),
534
+ decidedBy: "user",
535
+ reason: input.reason === undefined ? null : sanitizeNullableText(input.reason),
536
+ writesAcceptedMemory: false,
537
+ affectsRouting: false,
538
+ candidateStatus,
539
+ };
540
+ validateLearningReviewDecisionRecord(record);
541
+ return record;
542
+ }
543
+
544
+ export async function appendLearningReviewDecision(
545
+ homeDir: string,
546
+ record: LearningReviewDecisionRecord,
547
+ ): Promise<string> {
548
+ validateLearningReviewDecisionRecord(record);
549
+ const path = resolveLearningDecisionPath(homeDir);
550
+ await mkdir(dirname(path), { recursive: true });
551
+ await writeFile(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", flag: "a" });
552
+ return path;
553
+ }
554
+
555
+ export async function pathExists(path: string): Promise<boolean> {
556
+ try {
557
+ await stat(path);
558
+ return true;
559
+ } catch (error) {
560
+ if (
561
+ error instanceof Error &&
562
+ "code" in error &&
563
+ (error as NodeJS.ErrnoException).code === "ENOENT"
564
+ ) {
565
+ return false;
566
+ }
567
+ throw error;
568
+ }
569
+ }
570
+
571
+ function createSafePrivacy(): LearningCandidate["privacy"] {
572
+ return {
573
+ classification: "local-private",
574
+ containsSource: false,
575
+ containsSecrets: false,
576
+ containsInternalLinks: false,
577
+ containsPersonalData: false,
578
+ };
579
+ }
580
+
581
+ function sanitizeScope(scope: LearningCandidate["scope"]): LearningCandidate["scope"] {
582
+ return {
583
+ level: scope.level,
584
+ projectId: sanitizeNullableId(scope.projectId),
585
+ workflowId: sanitizeNullableId(scope.workflowId),
586
+ skillId: sanitizeNullableId(scope.skillId),
587
+ assetId: scope.assetId === undefined ? undefined : sanitizeNullableId(scope.assetId),
588
+ };
589
+ }
590
+
591
+ function requiredFields(candidate: LearningCandidate): Array<[string, unknown]> {
592
+ return [
593
+ ["id", candidate.id],
594
+ ["kind", candidate.kind],
595
+ ["status", candidate.status],
596
+ ["routingInfluence", candidate.routingInfluence],
597
+ ["scope.level", candidate.scope.level],
598
+ ["content.summary", candidate.content.summary],
599
+ ["content.howToApply", candidate.content.howToApply],
600
+ ["provenance.sourceType", candidate.provenance.sourceType],
601
+ ["provenance.createdAt", candidate.provenance.createdAt],
602
+ ["provenance.createdBy", candidate.provenance.createdBy],
603
+ ["privacy.classification", candidate.privacy.classification],
604
+ ["review.decision", candidate.review.decision],
605
+ ["retention.deleteAllowed", candidate.retention.deleteAllowed],
606
+ ["retention.exportAllowed", candidate.retention.exportAllowed],
607
+ ["confidence", candidate.confidence],
608
+ ];
609
+ }
610
+
611
+ async function parseJsonOrJsonlFile<T>(
612
+ path: string,
613
+ parseItem: (value: unknown) => T,
614
+ ): Promise<T[]> {
615
+ const raw = await readFile(path, "utf8");
616
+ if (raw.trim() === "") return [];
617
+
618
+ if (path.endsWith(".jsonl")) {
619
+ const lines = raw.split("\n").filter((line) => line.trim() !== "");
620
+ return lines.map((line) => parseItem(JSON.parse(line) as unknown));
621
+ }
622
+
623
+ const parsed = JSON.parse(raw) as unknown;
624
+ const values = Array.isArray(parsed) ? parsed : [parsed];
625
+ return values.map(parseItem);
626
+ }
627
+
628
+ function candidatePathFields(candidate: LearningCandidate): Array<[string, string]> {
629
+ return [
630
+ ["provenance.taskContractRef", candidate.provenance.taskContractRef],
631
+ ...candidate.provenance.evidenceRefs.map((ref, index): [string, string] => [
632
+ `provenance.evidenceRefs[${index}]`,
633
+ ref,
634
+ ]),
635
+ ].filter((entry): entry is [string, string] => typeof entry[1] === "string");
636
+ }
637
+
638
+ function isCandidateStale(candidate: LearningCandidate, now: string | undefined): boolean {
639
+ if (candidate.retention.staleAfter === null) return false;
640
+ const staleAfter = Date.parse(candidate.retention.staleAfter);
641
+ const current = Date.parse(now ?? new Date().toISOString());
642
+ return Number.isFinite(staleAfter) && Number.isFinite(current) && staleAfter < current;
643
+ }
644
+
645
+ function assertEnumValue(field: string, value: unknown, allowed: readonly string[]): void {
646
+ if (typeof value !== "string" || !allowed.includes(value)) {
647
+ throw new Error(`${field} must be one of: ${allowed.join(", ")}.`);
648
+ }
649
+ }
650
+
651
+ function assertStringField(field: string, value: unknown): void {
652
+ if (typeof value !== "string") {
653
+ throw new Error(`${field} must be a string.`);
654
+ }
655
+ }
656
+
657
+ function assertStringArrayField(field: string, value: unknown): void {
658
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
659
+ throw new Error(`${field} must be an array of strings.`);
660
+ }
661
+ }
662
+
663
+ function assertNoForbiddenContent(value: unknown): void {
664
+ if (typeof value === "string") {
665
+ if (value === "local-private") return;
666
+ if (SENSITIVE_TEXT_PATTERN.test(value)) {
667
+ throw new Error("Learning candidate contains sensitive content.");
668
+ }
669
+ return;
670
+ }
671
+ if (Array.isArray(value)) {
672
+ for (const item of value) assertNoForbiddenContent(item);
673
+ return;
674
+ }
675
+ if (!isRecord(value)) return;
676
+
677
+ for (const [key, child] of Object.entries(value)) {
678
+ const normalizedKey = normalizeKey(key);
679
+ if (FORBIDDEN_RAW_KEYS.has(normalizedKey)) {
680
+ throw new Error(`Learning candidate contains forbidden raw field: ${key}`);
681
+ }
682
+ assertNoForbiddenContent(child);
683
+ }
684
+ }
685
+
686
+ function sanitizeText(value: string): string {
687
+ return value.replace(SENSITIVE_TEXT_PATTERN, "[redacted]").slice(0, 500);
688
+ }
689
+
690
+ function sanitizeNullableText(value: string | null): string | null {
691
+ return value === null ? null : sanitizeText(value);
692
+ }
693
+
694
+ function sanitizeId(value: string): string {
695
+ const sanitized = sanitizeText(value)
696
+ .replace(/\[redacted\]/gi, "redacted")
697
+ .replace(/[^a-zA-Z0-9._-]/g, "-")
698
+ .replace(/-+/g, "-")
699
+ .replace(/^-+|-+$/g, "")
700
+ .slice(0, 100);
701
+ return sanitized || "learning-candidate";
702
+ }
703
+
704
+ function sanitizeNullableId(value: string | null): string | null {
705
+ return value === null ? null : sanitizeId(value);
706
+ }
707
+
708
+ function normalizeKey(key: string): string {
709
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
710
+ }
711
+
712
+ function isRecord(value: unknown): value is Record<string, unknown> {
713
+ return typeof value === "object" && value !== null && !Array.isArray(value);
714
+ }