0xray 4.0.22 → 4.0.24

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 (103) hide show
  1. package/.grok-plugin/plugin.json +1 -1
  2. package/.opencode/skills/orchestrator/SKILL.md +7 -7
  3. package/.xray/features.json +11 -1
  4. package/.xray/features.schema.json +17 -0
  5. package/AGENTS-consumer.md +1 -1
  6. package/AGENTS.md +1 -1
  7. package/README.md +2 -2
  8. package/SKILLS.md +1 -1
  9. package/dist/AGENTS.md +1 -1
  10. package/dist/CHANGELOG.md +44 -1
  11. package/dist/README.md +2 -2
  12. package/dist/SKILLS.md +1 -1
  13. package/dist/cli/index.js +131 -100
  14. package/dist/core/features-config.d.ts +4 -0
  15. package/dist/core/features-config.js +10 -0
  16. package/dist/inference/inference-accumulator.d.ts +2 -0
  17. package/dist/inference/inference-accumulator.js +110 -6
  18. package/dist/inference/inference-cycle.d.ts +12 -0
  19. package/dist/inference/inference-cycle.js +159 -16
  20. package/dist/inference/inference-proposal-generator.js +206 -5
  21. package/dist/inference/inference-run-gate.d.ts +8 -0
  22. package/dist/inference/inference-run-gate.js +10 -0
  23. package/dist/inference/session-capture.d.ts +31 -0
  24. package/dist/inference/session-capture.js +277 -1
  25. package/dist/integrations/cursor/hooks/pre-compact.js +89 -1
  26. package/dist/integrations/governance/index.d.ts +5 -0
  27. package/dist/integrations/governance/index.js +22 -2
  28. package/dist/integrations/governance/types.d.ts +11 -0
  29. package/dist/integrations/governance/types.js +16 -0
  30. package/dist/integrations/hooks/plates.cjs +165 -0
  31. package/dist/integrations/hooks/station-hook-runtime.cjs +29 -11
  32. package/dist/integrations/hooks/station-memory-ingest.mjs +109 -42
  33. package/dist/integrations/openclaw/plugin/xray-pre-tool/package.json +1 -1
  34. package/dist/mcps/governance.server.d.ts +2 -0
  35. package/dist/mcps/governance.server.js +12 -1
  36. package/dist/mcps/researcher-confidence.d.ts +5 -1
  37. package/dist/mcps/researcher-confidence.js +37 -0
  38. package/dist/mcps/researcher.server.js +2 -2
  39. package/dist/memory-routing/index.d.ts +1 -1
  40. package/dist/memory-routing/index.js +1 -1
  41. package/dist/memory-routing/lesson-pickup.d.ts +2 -0
  42. package/dist/memory-routing/lesson-pickup.js +57 -0
  43. package/dist/memory-routing/plates.d.ts +16 -0
  44. package/dist/memory-routing/plates.js +71 -0
  45. package/dist/memory-routing/provider-loader.d.ts +5 -0
  46. package/dist/memory-routing/provider-loader.js +46 -0
  47. package/dist/memory-routing/provider-registry.d.ts +7 -2
  48. package/dist/memory-routing/provider-registry.js +26 -6
  49. package/dist/memory-routing/record-lesson.d.ts +16 -0
  50. package/dist/memory-routing/record-lesson.js +26 -0
  51. package/dist/memory-routing/types.d.ts +17 -0
  52. package/dist/memory-routing/types.js +2 -1
  53. package/dist/nucleus/thin-dispatch.d.ts +2 -0
  54. package/dist/nucleus/thin-dispatch.js +3 -2
  55. package/dist/processors/implementations/inference-improvement-processor.d.ts +5 -0
  56. package/dist/processors/implementations/inference-improvement-processor.js +101 -2
  57. package/dist/processors/implementations/storytelling-trigger-processor.js +1 -11
  58. package/dist/processors/processor-manager.d.ts +1 -1
  59. package/dist/processors/processor-manager.js +52 -20
  60. package/dist/skills/orchestrator/SKILL.md +7 -7
  61. package/llms.txt +1 -1
  62. package/package.json +1 -1
  63. package/scripts/foundry/plant/skills/mill/SKILL.md +1 -1
  64. package/scripts/foundry/reconcile-version.mjs +23 -1
  65. package/scripts/foundry/release.mjs +1 -1
  66. package/scripts/foundry/version-manager.mjs +53 -0
  67. package/scripts/node/consumer-install-smoke.mjs +3 -3
  68. package/src/integrations/openclaw/plugin/xray-pre-tool/package.json +1 -1
  69. package/src/mcps/governance.server.ts +12 -1
  70. package/src/mcps/researcher-confidence.ts +56 -3
  71. package/src/mcps/researcher.server.ts +4 -5
  72. package/src/skills/orchestrator/SKILL.md +7 -7
  73. package/vendor/@0xray/repertoire/README.md +3 -1
  74. package/vendor/@0xray/repertoire/data/stack-overlay.json +4 -4
  75. package/vendor/@0xray/repertoire/data/subject-overlay.json +3 -3
  76. package/vendor/@0xray/repertoire/dist/RepertoireService.d.ts +4 -0
  77. package/vendor/@0xray/repertoire/dist/RepertoireService.d.ts.map +1 -1
  78. package/vendor/@0xray/repertoire/dist/RepertoireService.js +4 -4
  79. package/vendor/@0xray/repertoire/dist/RepertoireService.js.map +1 -1
  80. package/vendor/@0xray/repertoire/dist/ingestion/xray-session-ingester.d.ts.map +1 -1
  81. package/vendor/@0xray/repertoire/dist/ingestion/xray-session-ingester.js.map +1 -1
  82. package/vendor/@0xray/repertoire/dist/orchestrator-bridge/confidence-gate.d.ts.map +1 -1
  83. package/vendor/@0xray/repertoire/dist/orchestrator-bridge/confidence-gate.js +5 -0
  84. package/vendor/@0xray/repertoire/dist/orchestrator-bridge/confidence-gate.js.map +1 -1
  85. package/vendor/@0xray/repertoire/dist/orchestrator-bridge/signal-injector.d.ts.map +1 -1
  86. package/vendor/@0xray/repertoire/dist/orchestrator-bridge/signal-injector.js +16 -0
  87. package/vendor/@0xray/repertoire/dist/orchestrator-bridge/signal-injector.js.map +1 -1
  88. package/vendor/@0xray/repertoire/dist/paths.d.ts +3 -3
  89. package/vendor/@0xray/repertoire/dist/paths.d.ts.map +1 -1
  90. package/vendor/@0xray/repertoire/dist/paths.js.map +1 -1
  91. package/vendor/@0xray/repertoire/dist/provider/memory-routing-provider.d.ts +11 -0
  92. package/vendor/@0xray/repertoire/dist/provider/memory-routing-provider.d.ts.map +1 -1
  93. package/vendor/@0xray/repertoire/dist/provider/memory-routing-provider.js +2 -0
  94. package/vendor/@0xray/repertoire/dist/provider/memory-routing-provider.js.map +1 -1
  95. package/vendor/@0xray/repertoire/dist/registry/CuratedSignalsManager.d.ts +74 -4
  96. package/vendor/@0xray/repertoire/dist/registry/CuratedSignalsManager.d.ts.map +1 -1
  97. package/vendor/@0xray/repertoire/dist/registry/CuratedSignalsManager.js +431 -13
  98. package/vendor/@0xray/repertoire/dist/registry/CuratedSignalsManager.js.map +1 -1
  99. package/vendor/@0xray/repertoire/dist/types.d.ts +21 -0
  100. package/vendor/@0xray/repertoire/dist/types.d.ts.map +1 -1
  101. package/vendor/@0xray/repertoire/package.json +1 -1
  102. package/xray/features.json +11 -1
  103. package/xray/features.schema.json +17 -0
@@ -1,7 +1,8 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ import { captureReflectionInference } from "./session-capture.js";
3
4
  export function shouldTriggerCycle(inferenceDir, lastCycleFile) {
4
- const sessions = loadSessionInferences(inferenceDir);
5
+ const sessions = loadCorpusSessions(inferenceDir);
5
6
  if (sessions.length < 1) {
6
7
  return { trigger: false, reason: `no session files collected` };
7
8
  }
@@ -20,17 +21,18 @@ export function shouldTriggerCycle(inferenceDir, lastCycleFile) {
20
21
  };
21
22
  }
22
23
  export function accumulateCorpus(inferenceDir) {
23
- const sessions = loadSessionInferences(inferenceDir);
24
+ const sessions = loadCorpusSessions(inferenceDir);
24
25
  const totalCommits = sessions.reduce((sum, s) => sum + s.metrics.commits, 0);
25
26
  const patternMap = new Map();
26
27
  for (const session of sessions) {
27
28
  for (const p of session.patterns) {
28
29
  const existing = patternMap.get(p.name);
30
+ const evidence = patternEvidence(p);
29
31
  if (existing) {
30
32
  existing.count++;
31
33
  existing.confidence.push(p.confidence);
32
34
  existing.sessions.push(session.sessionId);
33
- for (const e of p.evidence) {
35
+ for (const e of evidence) {
34
36
  if (!existing.evidence.includes(e))
35
37
  existing.evidence.push(e);
36
38
  }
@@ -40,7 +42,7 @@ export function accumulateCorpus(inferenceDir) {
40
42
  count: 1,
41
43
  confidence: [p.confidence],
42
44
  sessions: [session.sessionId],
43
- evidence: [...p.evidence],
45
+ evidence: [...evidence],
44
46
  description: p.description,
45
47
  });
46
48
  }
@@ -88,6 +90,77 @@ export function accumulateCorpus(inferenceDir) {
88
90
  collectedAt: new Date().toISOString(),
89
91
  };
90
92
  }
93
+ function loadCorpusSessions(inferenceDir) {
94
+ return [...loadSessionInferences(inferenceDir), ...loadReflectionInferences(inferenceDir)];
95
+ }
96
+ /** Reflection files are graded in memory. They are not copied into session-*.json. */
97
+ export function loadReflectionInferences(inferenceDir) {
98
+ const projectRoot = path.resolve(inferenceDir, "..", "..");
99
+ const signalNames = readDestSignalNames(projectRoot);
100
+ const root = path.join(projectRoot, "docs", "reflections");
101
+ const sessions = [];
102
+ for (const file of listMarkdown(root)) {
103
+ const relativePath = path.relative(root, file);
104
+ if (/template/i.test(path.basename(file)))
105
+ continue;
106
+ let text = "";
107
+ try {
108
+ text = fs.readFileSync(file, "utf-8");
109
+ }
110
+ catch {
111
+ continue;
112
+ }
113
+ const modifiedAt = fs.statSync(file).mtime;
114
+ const session = captureReflectionInference({ relativePath, text, modifiedAt, signalNames });
115
+ if (session)
116
+ sessions.push(session);
117
+ }
118
+ return sessions;
119
+ }
120
+ function listMarkdown(dir) {
121
+ if (!fs.existsSync(dir))
122
+ return [];
123
+ const found = [];
124
+ for (const name of fs.readdirSync(dir)) {
125
+ const full = path.join(dir, name);
126
+ let stat;
127
+ try {
128
+ stat = fs.statSync(full);
129
+ }
130
+ catch {
131
+ continue;
132
+ }
133
+ if (stat.isDirectory()) {
134
+ found.push(...listMarkdown(full));
135
+ continue;
136
+ }
137
+ if (name.endsWith(".md"))
138
+ found.push(full);
139
+ }
140
+ return found;
141
+ }
142
+ function readDestSignalNames(projectRoot) {
143
+ const dest = path.join(projectRoot, ".xray", "state", "repertoire", "curated_signals.json");
144
+ if (!fs.existsSync(dest))
145
+ return [];
146
+ try {
147
+ const parsed = JSON.parse(fs.readFileSync(dest, "utf-8"));
148
+ if (!Array.isArray(parsed.signals))
149
+ return [];
150
+ const names = [];
151
+ for (const signal of parsed.signals) {
152
+ if (!signal || typeof signal !== "object")
153
+ continue;
154
+ const name = signal.name;
155
+ if (typeof name === "string" && name.trim().length > 0)
156
+ names.push(name.trim());
157
+ }
158
+ return names;
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ }
91
164
  export function loadSessionInferences(dir) {
92
165
  if (!fs.existsSync(dir))
93
166
  return [];
@@ -95,13 +168,44 @@ export function loadSessionInferences(dir) {
95
168
  .filter((f) => f.startsWith("session-") && f.endsWith(".json"))
96
169
  .map((f) => {
97
170
  try {
98
- return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
171
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
172
+ const named = readMatchedPrimitives(parsed);
173
+ parsed.matched_primitives = named;
174
+ parsed.matchedPrimitives = named;
175
+ return parsed;
99
176
  }
100
177
  catch {
101
178
  return null;
102
179
  }
103
180
  })
104
- .filter((s) => s !== null);
181
+ .filter((s) => isSessionInference(s));
182
+ }
183
+ /** Captured patterns sometimes omit evidence. That is an empty list, not a throw. */
184
+ function readMatchedPrimitives(value) {
185
+ if (!value || typeof value !== "object")
186
+ return [];
187
+ const raw = value.matched_primitives
188
+ ?? value.matchedPrimitives;
189
+ if (!Array.isArray(raw))
190
+ return [];
191
+ return [...new Set(raw.filter((item) => typeof item === "string" && item.length > 0))];
192
+ }
193
+ function patternEvidence(pattern) {
194
+ if (!Array.isArray(pattern.evidence))
195
+ return [];
196
+ return pattern.evidence.filter((item) => typeof item === "string");
197
+ }
198
+ /** Notes and partial JSON under docs/inference are not sessions. */
199
+ function isSessionInference(value) {
200
+ if (!value || typeof value !== "object")
201
+ return false;
202
+ const session = value;
203
+ return typeof session.sessionId === "string"
204
+ && Array.isArray(session.patterns)
205
+ && Array.isArray(session.problems)
206
+ && Array.isArray(session.approaches)
207
+ && Array.isArray(session.wrongTurns)
208
+ && typeof session.metrics?.commits === "number";
105
209
  }
106
210
  function loadLastCycleDate(filePath) {
107
211
  if (!fs.existsSync(filePath))
@@ -8,6 +8,10 @@ export interface InferenceProposal {
8
8
  confidence: number;
9
9
  source: "recurring_problem" | "recurring_pattern" | "wrong_turn";
10
10
  status: "pending" | "approved" | "rejected" | "applied" | "failed";
11
+ /** Repertoire signals the sessions behind this proposal already named. */
12
+ namedSignals?: string[];
13
+ /** Approaches, solutions, or the wrong turn from those sessions. */
14
+ lesson?: string;
11
15
  }
12
16
  export interface InferenceCycleResult {
13
17
  cycleId: string;
@@ -55,7 +59,11 @@ export declare class InferenceCycle {
55
59
  constructor(projectRoot?: string, agentInvoker?: AgentInvoker, options?: InferenceCycleOptions);
56
60
  governExternalProposals(proposals: InferenceProposal[]): Promise<InferenceCycleResult>;
57
61
  maybeRunCycle(): Promise<InferenceCycleResult>;
62
+ /** Approve or reject grades the signals that proposal's sessions already named. needs-revision does not write. */
63
+ private recordGovernedLessons;
58
64
  private governProposals;
65
+ /** Flag off: the local matrix is the pass. Dynamo is not called and the miss is not stored as a reject. */
66
+ private governWithLocalMatrix;
59
67
  private isGovernanceMcpPreferred;
60
68
  private parseGovernanceMcpResponse;
61
69
  private extractTextFromNdjson;
@@ -64,6 +72,10 @@ export declare class InferenceCycle {
64
72
  getPhase(): CyclePhase;
65
73
  private saveCycleState;
66
74
  private appendHistory;
75
+ /** Lesson ids that leave the 50-entry window stay covered. Empty cycles do not erase them. */
76
+ private retainDroppedLessonIds;
77
+ private readRetainedLessonIds;
78
+ private retainedLessonCycle;
67
79
  private loadHistory;
68
80
  private buildBlockedResult;
69
81
  private buildResult;
@@ -8,8 +8,27 @@ import { mcpClientManager } from "../mcps/mcp-client.js";
8
8
  import { getConfigDir } from "../core/config-paths.js";
9
9
  import { generateProposals } from "./inference-proposal-generator.js";
10
10
  import { applyProposals as applyProposalsEx } from "./inference-applier.js";
11
+ import { applyDecisionMatrix } from "../governance/governance-core.js";
12
+ import { recordLesson } from "../memory-routing/record-lesson.js";
13
+ import { writeLessonPickup } from "../memory-routing/lesson-pickup.js";
11
14
  const CYCLE_STATE_FILE = "inference-cycle-state.json";
12
15
  const CYCLE_HISTORY_FILE = "inference-cycle-history.json";
16
+ const GRADED_LESSON_IDS_FILE = "inference-cycle-graded-ids.json";
17
+ const HISTORY_CAP = 50;
18
+ const LESSON_ID_PREFIXES = ["named:", "pattern:", "problem:", "wrong:"];
19
+ function isLessonProposalId(id) {
20
+ return LESSON_ID_PREFIXES.some((prefix) => id.startsWith(prefix));
21
+ }
22
+ function lessonIdsFromUnknown(parsed) {
23
+ if (!Array.isArray(parsed))
24
+ return [];
25
+ const ids = [];
26
+ for (const item of parsed) {
27
+ if (typeof item === "string" && isLessonProposalId(item))
28
+ ids.push(item);
29
+ }
30
+ return ids;
31
+ }
13
32
  function governanceToolText(result) {
14
33
  if (typeof result !== "object" || result === null || !("content" in result)) {
15
34
  return "";
@@ -18,6 +37,12 @@ function governanceToolText(result) {
18
37
  const text = content?.[0]?.text;
19
38
  return typeof text === "string" ? text : "";
20
39
  }
40
+ function governanceToolIsError(result) {
41
+ if (typeof result !== "object" || result === null || !("isError" in result)) {
42
+ return false;
43
+ }
44
+ return result.isError === true;
45
+ }
21
46
  export class InferenceCycle {
22
47
  static instances = new Map();
23
48
  static reEntryLock = false;
@@ -94,6 +119,7 @@ export class InferenceCycle {
94
119
  p.status = "rejected";
95
120
  for (const p of proposals)
96
121
  InferenceCycle.governedProposalIds.add(p.id);
122
+ this.recordGovernedLessons(cycleId, proposals, votes);
97
123
  this.setPhase("complete");
98
124
  this.saveCycleState(cycleId);
99
125
  const result = this.buildResult(cycleId, true, "external proposals", startTime, undefined, proposals, votes);
@@ -169,6 +195,7 @@ export class InferenceCycle {
169
195
  for (const p of proposals.filter((p) => p.status !== "approved")) {
170
196
  p.status = "rejected";
171
197
  }
198
+ this.recordGovernedLessons(cycleId, proposals, votes);
172
199
  if (approved.length > 0) {
173
200
  if (!this.options.skipApply) {
174
201
  this.setPhase("applying");
@@ -215,6 +242,39 @@ export class InferenceCycle {
215
242
  InferenceCycle.reEntryLock = false;
216
243
  }
217
244
  }
245
+ /** Approve or reject grades the signals that proposal's sessions already named. needs-revision does not write. */
246
+ recordGovernedLessons(cycleId, proposals, votes) {
247
+ const alreadyGraded = new Set(this.loadHistory().flatMap((cycle) => (cycle.proposals ?? []).map((proposal) => proposal.id)));
248
+ for (const proposal of proposals) {
249
+ if (alreadyGraded.has(proposal.id))
250
+ continue;
251
+ const vote = votes.find((item) => item.proposalId === proposal.id);
252
+ if (!vote)
253
+ continue;
254
+ if (vote.decision !== "approve" && vote.decision !== "reject")
255
+ continue;
256
+ if (vote.details.some((line) => line.includes("governance error")))
257
+ continue;
258
+ const named = proposal.namedSignals ?? [];
259
+ const taught = recordLesson({
260
+ operation: [proposal.title, proposal.description, ...proposal.evidence].join("\n"),
261
+ success: vote.decision === "approve",
262
+ taskId: proposal.id,
263
+ assignedAgent: "inference-cycle",
264
+ sessionId: cycleId,
265
+ signals: named,
266
+ lesson: proposal.lesson ?? "",
267
+ });
268
+ if (taught.length > 0) {
269
+ frameworkLogger.log("inference-cycle", "lesson-recorded", "info", {
270
+ proposalId: proposal.id,
271
+ decision: vote.decision,
272
+ signals: taught,
273
+ });
274
+ }
275
+ }
276
+ writeLessonPickup(this.projectRoot);
277
+ }
218
278
  async governProposals(proposals) {
219
279
  // Governance MCP is the sole governance path (governance.server.ts + GovernanceService).
220
280
  // It calls individual skill MCPs (code-review, security-audit, researcher) internally
@@ -222,8 +282,9 @@ export class InferenceCycle {
222
282
  const useGovernanceMcp = process.env.XRAY_FORCE_MCP_GOVERNANCE === 'true' ||
223
283
  this.isGovernanceMcpPreferred();
224
284
  if (!useGovernanceMcp) {
225
- throw new Error("Governance MCP is required but not available");
285
+ return this.governWithLocalMatrix(proposals);
226
286
  }
287
+ const governanceMcpTimeoutMs = 90_000;
227
288
  const result = await Promise.race([
228
289
  mcpClientManager.callServerTool("governance", "govern_proposals", {
229
290
  proposals: proposals.map(p => ({
@@ -238,9 +299,12 @@ export class InferenceCycle {
238
299
  context: { source: "inference-cycle" },
239
300
  options: { require_external: true },
240
301
  }),
241
- new Promise((_, reject) => setTimeout(() => reject(new Error("Governance MCP timed out after 8s")), 8000)),
302
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Governance MCP timed out after ${governanceMcpTimeoutMs / 1000}s`)), governanceMcpTimeoutMs)),
242
303
  ]);
243
304
  const text = governanceToolText(result);
305
+ if (governanceToolIsError(result)) {
306
+ throw new Error(text || "Governance MCP returned an error");
307
+ }
244
308
  const parsed = this.parseGovernanceMcpResponse(text, proposals);
245
309
  frameworkLogger.log("inference-cycle", "governance-mcp-primary-path", "info", {
246
310
  proposalCount: proposals.length,
@@ -248,6 +312,24 @@ export class InferenceCycle {
248
312
  });
249
313
  return parsed.votes;
250
314
  }
315
+ /** Flag off: the local matrix is the pass. Dynamo is not called and the miss is not stored as a reject. */
316
+ governWithLocalMatrix(proposals) {
317
+ return proposals.map((proposal) => {
318
+ const resonance = Number.isFinite(proposal.confidence) ? proposal.confidence : 0.8;
319
+ const matrix = applyDecisionMatrix({ resonance });
320
+ const decision = matrix.recommendation === "PASS"
321
+ ? "approve"
322
+ : matrix.recommendation === "REJECT"
323
+ ? "reject"
324
+ : "needs_revision";
325
+ return {
326
+ proposalId: proposal.id,
327
+ decision,
328
+ confidence: matrix.confidence,
329
+ details: ["local-matrix: inference_governance disabled", ...matrix.reasons],
330
+ };
331
+ });
332
+ }
251
333
  isGovernanceMcpPreferred() {
252
334
  try {
253
335
  const config = featuresConfigLoader.loadConfig();
@@ -275,17 +357,12 @@ export class InferenceCycle {
275
357
  return { votes, overallDecision: data.overallDecision || "needs_revision" };
276
358
  }
277
359
  catch {
360
+ const preview = text.substring(0, 300);
278
361
  frameworkLogger.log('inference-cycle', 'governance-mcp-parse-failed', 'warning', {
279
- textPreview: text.substring(0, 200),
362
+ textPreview: preview,
280
363
  proposalCount: proposals.length,
281
364
  });
282
- const votes = proposals.map(p => ({
283
- proposalId: p.id,
284
- decision: "abstain",
285
- confidence: 0.5,
286
- details: ["governance-mcp: parse-failed"],
287
- }));
288
- return { votes, overallDecision: "needs_revision" };
365
+ throw new Error(`governance-mcp: unreadable vote: ${preview || "empty governance response"}`);
289
366
  }
290
367
  }
291
368
  extractTextFromNdjson(output) {
@@ -357,21 +434,87 @@ export class InferenceCycle {
357
434
  }
358
435
  }
359
436
  history.push(result);
360
- if (history.length > 50)
361
- history = history.slice(-50);
437
+ const dropped = history.length > HISTORY_CAP ? history.slice(0, history.length - HISTORY_CAP) : [];
438
+ if (history.length > HISTORY_CAP)
439
+ history = history.slice(-HISTORY_CAP);
440
+ this.retainDroppedLessonIds(dropped, history);
362
441
  fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
363
442
  }
364
- loadHistory() {
365
- const historyPath = path.join(this.stateDir, CYCLE_HISTORY_FILE);
366
- if (!fs.existsSync(historyPath))
443
+ /** Lesson ids that leave the 50-entry window stay covered. Empty cycles do not erase them. */
444
+ retainDroppedLessonIds(dropped, kept) {
445
+ if (dropped.length === 0)
446
+ return;
447
+ const keptIds = new Set(kept.flatMap((cycle) => (cycle.proposals ?? []).map((proposal) => proposal.id)));
448
+ const retained = new Set(this.readRetainedLessonIds());
449
+ let changed = false;
450
+ for (const cycle of dropped) {
451
+ for (const proposal of cycle.proposals ?? []) {
452
+ if (!isLessonProposalId(proposal.id))
453
+ continue;
454
+ if (keptIds.has(proposal.id) || retained.has(proposal.id))
455
+ continue;
456
+ retained.add(proposal.id);
457
+ changed = true;
458
+ }
459
+ }
460
+ if (!changed)
461
+ return;
462
+ if (!fs.existsSync(this.stateDir)) {
463
+ fs.mkdirSync(this.stateDir, { recursive: true });
464
+ }
465
+ fs.writeFileSync(path.join(this.stateDir, GRADED_LESSON_IDS_FILE), JSON.stringify([...retained].sort(), null, 2));
466
+ }
467
+ readRetainedLessonIds() {
468
+ const file = path.join(this.stateDir, GRADED_LESSON_IDS_FILE);
469
+ if (!fs.existsSync(file))
367
470
  return [];
368
471
  try {
369
- return JSON.parse(fs.readFileSync(historyPath, "utf-8"));
472
+ return lessonIdsFromUnknown(JSON.parse(fs.readFileSync(file, "utf-8")));
370
473
  }
371
474
  catch {
372
475
  return [];
373
476
  }
374
477
  }
478
+ retainedLessonCycle(ids) {
479
+ return {
480
+ cycleId: "retained-lessons",
481
+ triggered: false,
482
+ triggerReason: "lesson ids kept past the history cap",
483
+ corpusSummary: { sessions: 0, totalCommits: 0, recurringPatterns: 0, recurringProblems: 0 },
484
+ proposals: ids.map((id) => ({
485
+ id,
486
+ type: "codify",
487
+ title: id,
488
+ description: "",
489
+ evidence: [],
490
+ confidence: 0,
491
+ source: "recurring_pattern",
492
+ status: "pending",
493
+ })),
494
+ votes: [],
495
+ phase: "complete",
496
+ completedAt: "1970-01-01T00:00:00.000Z",
497
+ duration: 0,
498
+ };
499
+ }
500
+ loadHistory() {
501
+ const historyPath = path.join(this.stateDir, CYCLE_HISTORY_FILE);
502
+ let history = [];
503
+ if (fs.existsSync(historyPath)) {
504
+ try {
505
+ const parsed = JSON.parse(fs.readFileSync(historyPath, "utf-8"));
506
+ history = Array.isArray(parsed) ? parsed : [];
507
+ }
508
+ catch {
509
+ history = [];
510
+ }
511
+ }
512
+ const present = new Set(history.flatMap((cycle) => (cycle.proposals ?? []).map((proposal) => proposal.id)));
513
+ const missing = this.readRetainedLessonIds().filter((id) => !present.has(id));
514
+ if (missing.length === 0)
515
+ return history;
516
+ return [this.retainedLessonCycle(missing), ...history];
517
+ }
375
518
  buildBlockedResult(cycleId, reason) {
376
519
  return {
377
520
  cycleId,