soturail 0.8.0 → 0.9.0

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 (57) hide show
  1. package/README.md +57 -16
  2. package/dist/commands/bench.d.ts +1 -0
  3. package/dist/commands/bench.js +18 -6
  4. package/dist/commands/bench.js.map +1 -1
  5. package/dist/commands/brain.js +14 -7
  6. package/dist/commands/brain.js.map +1 -1
  7. package/dist/commands/native.js +12 -0
  8. package/dist/commands/native.js.map +1 -1
  9. package/dist/commands/rules.js +20 -1
  10. package/dist/commands/rules.js.map +1 -1
  11. package/dist/commands/self.js +19 -0
  12. package/dist/commands/self.js.map +1 -1
  13. package/dist/core/baseline-snapshot.d.ts +27 -0
  14. package/dist/core/baseline-snapshot.js +225 -0
  15. package/dist/core/baseline-snapshot.js.map +1 -0
  16. package/dist/core/benchmark-rail.d.ts +44 -0
  17. package/dist/core/benchmark-rail.js +322 -0
  18. package/dist/core/benchmark-rail.js.map +1 -0
  19. package/dist/core/config.d.ts +5 -0
  20. package/dist/core/config.js +6 -1
  21. package/dist/core/config.js.map +1 -1
  22. package/dist/core/evaluation-suite.js +117 -2
  23. package/dist/core/evaluation-suite.js.map +1 -1
  24. package/dist/core/evidence-pack.js +17 -0
  25. package/dist/core/evidence-pack.js.map +1 -1
  26. package/dist/core/harness-rail.js +2 -0
  27. package/dist/core/harness-rail.js.map +1 -1
  28. package/dist/core/native-candidates.d.ts +44 -0
  29. package/dist/core/native-candidates.js +163 -0
  30. package/dist/core/native-candidates.js.map +1 -0
  31. package/dist/core/project-brain.d.ts +70 -4
  32. package/dist/core/project-brain.js +475 -30
  33. package/dist/core/project-brain.js.map +1 -1
  34. package/dist/core/release-preflight.js +8 -0
  35. package/dist/core/release-preflight.js.map +1 -1
  36. package/dist/core/version.d.ts +1 -1
  37. package/dist/core/version.js +1 -1
  38. package/docs/agents.md +9 -1
  39. package/docs/baseline-snapshots.md +54 -0
  40. package/docs/benchmarking.md +69 -0
  41. package/docs/code-graph.md +33 -0
  42. package/docs/diagram-rail.md +2 -0
  43. package/docs/evaluation-suite.md +23 -0
  44. package/docs/harness-rail.md +10 -0
  45. package/docs/knowledge-to-rules.md +23 -0
  46. package/docs/memory-rail.md +2 -0
  47. package/docs/native-performance-policy.md +62 -0
  48. package/docs/native-runner.md +24 -0
  49. package/docs/project-brain.md +101 -4
  50. package/docs/release-workflow.md +13 -0
  51. package/docs/releases/README.md +2 -0
  52. package/docs/releases/RELEASE_NOTES_v0.8.1.md +42 -0
  53. package/docs/releases/RELEASE_NOTES_v0.9.0.md +35 -0
  54. package/docs/reverse-specification-rail.md +19 -0
  55. package/docs/rules.md +3 -0
  56. package/docs/workflow-rail.md +23 -0
  57. package/package.json +1 -1
@@ -41,7 +41,12 @@ export async function initBrain(root = process.cwd()) {
41
41
  paths.brainProjectProfileFile,
42
42
  paths.brainArchitectureFile,
43
43
  paths.brainIndexFile,
44
- paths.brainFreshnessFile
44
+ paths.brainFreshnessFile,
45
+ paths.brainConsolidatedClaimsFile,
46
+ paths.brainConsolidationReportJson,
47
+ paths.brainConsolidationReportMd,
48
+ paths.brainRepairPlanJson,
49
+ paths.brainRepairPlanMd
45
50
  ].map((filePath) => relativeToRoot(root, filePath));
46
51
  return {
47
52
  paths: createdPaths,
@@ -124,6 +129,8 @@ export async function scanBrain(root = process.cwd()) {
124
129
  `tests_detected: ${tests.length}`,
125
130
  "next_commands:",
126
131
  "- soturail brain recall \"release notes\"",
132
+ "- soturail brain consolidate --dry-run",
133
+ "- soturail brain stale --repair-plan",
127
134
  "- soturail rules from-brain",
128
135
  "- soturail reverse gaps"
129
136
  ].join("\n") + "\n"
@@ -150,7 +157,8 @@ export async function brainProfile(root = process.cwd()) {
150
157
  `gaps: ${counts.gaps}`,
151
158
  `suspect_or_stale: ${counts.suspectOrStale}`,
152
159
  "next_commands:",
153
- "- soturail brain stale",
160
+ "- soturail brain consolidate --dry-run",
161
+ "- soturail brain stale --repair-plan",
154
162
  "- soturail brain export --agent generic",
155
163
  "- soturail reverse specs ./src"
156
164
  ].join("\n") + "\n";
@@ -189,7 +197,7 @@ export async function recallBrain(query, root = process.cwd(), limit = 8) {
189
197
  ])
190
198
  ].join("\n").trimEnd() + "\n";
191
199
  }
192
- export async function staleBrain(root = process.cwd()) {
200
+ export async function staleBrain(root = process.cwd(), options = {}) {
193
201
  await initBrain(root);
194
202
  const paths = getWorkspacePaths(root);
195
203
  const claims = await readJsonl(paths.brainClaimsFile);
@@ -199,19 +207,38 @@ export async function staleBrain(root = process.cwd()) {
199
207
  for (const claim of claims) {
200
208
  const absolute = path.resolve(root, claim.sourcePath);
201
209
  if (!(await exists(absolute))) {
202
- events.push(staleEvent(claim.id, "source file missing", claim.rangeHash, "missing", "stale"));
210
+ events.push(staleEvent(claim.id, "source file missing", claim.rangeHash, "missing", "stale", { previousRange: claim.sourceRange }));
203
211
  continue;
204
212
  }
205
213
  const evidence = await sourceEvidence(root, claim.sourcePath, undefined, claim.sourceRange);
206
214
  if (claim.rangeHash !== evidence.rangeHash) {
207
- events.push(staleEvent(claim.id, "rangeHash changed", claim.rangeHash, evidence.rangeHash, "suspect"));
215
+ const relocation = await relocateSourceRange(root, claim);
216
+ if (relocation.status === "relocated") {
217
+ events.push(staleEvent(claim.id, "source range relocated", claim.rangeHash, relocation.rangeHash, "relocated", {
218
+ previousRange: claim.sourceRange,
219
+ ...(relocation.range ? { candidateRange: relocation.range } : {}),
220
+ similarity: relocation.similarity
221
+ }));
222
+ }
223
+ else if (relocation.status === "candidate") {
224
+ events.push(staleEvent(claim.id, "rangeHash changed; relocation candidate", claim.rangeHash, evidence.rangeHash, "suspect", {
225
+ previousRange: claim.sourceRange,
226
+ ...(relocation.range ? { candidateRange: relocation.range } : {}),
227
+ similarity: relocation.similarity
228
+ }));
229
+ }
230
+ else {
231
+ events.push(staleEvent(claim.id, "rangeHash changed", claim.rangeHash, evidence.rangeHash, "suspect", {
232
+ previousRange: claim.sourceRange
233
+ }));
234
+ }
208
235
  }
209
236
  else if (claim.fileHash !== evidence.fileHash) {
210
237
  warnings.push(`${claim.id}: fileHash changed but rangeHash is unchanged`);
211
238
  }
212
239
  for (const validationPath of claim.validatedBy) {
213
240
  if (!(await exists(path.resolve(root, validationPath)))) {
214
- events.push(staleEvent(claim.id, `validatedBy missing: ${validationPath}`, claim.rangeHash, "missing-validation", "suspect"));
241
+ events.push(staleEvent(claim.id, `validatedBy missing: ${validationPath}`, claim.rangeHash, "missing-validation", "suspect", { previousRange: claim.sourceRange }));
215
242
  }
216
243
  }
217
244
  }
@@ -228,22 +255,71 @@ export async function staleBrain(root = process.cwd()) {
228
255
  events
229
256
  };
230
257
  await writeJson(paths.brainFreshnessFile, freshness);
258
+ const repairPlan = options.repairPlan ? await writeRepairPlan(root, claims, events) : undefined;
231
259
  return {
232
260
  freshness,
261
+ ...(repairPlan ? { repairPlan } : {}),
233
262
  output: [
234
263
  "SotuRail brain stale",
235
264
  `checked_records: ${freshness.checkedRecords}`,
236
265
  `new_events: ${newEvents.length}`,
237
266
  `suspect: ${freshness.suspect}`,
238
267
  `stale: ${freshness.stale}`,
268
+ `relocated: ${freshness.events.filter((event) => event.status === "relocated").length}`,
239
269
  `warnings: ${freshness.warnings.length}`,
240
270
  `freshness: ${relativeToRoot(root, paths.brainFreshnessFile)}`,
271
+ ...(repairPlan ? [
272
+ `repair_plan_json: ${relativeToRoot(root, paths.brainRepairPlanJson)}`,
273
+ `repair_plan_md: ${relativeToRoot(root, paths.brainRepairPlanMd)}`
274
+ ] : []),
241
275
  "",
242
276
  ...(newEvents.length > 0 ? newEvents.map((event) => `- ${event.recordId}: ${event.reason} [${event.status}]`) : ["No stale evidence drift detected."])
243
277
  ].join("\n") + "\n"
244
278
  };
245
279
  }
246
- export async function brainDoctor(root = process.cwd()) {
280
+ export async function consolidateBrain(root = process.cwd(), options = {}) {
281
+ await initBrain(root);
282
+ const paths = getWorkspacePaths(root);
283
+ const claims = await readJsonl(paths.brainClaimsFile);
284
+ const dryRun = options.dryRun !== false;
285
+ const groups = findDuplicateClaimGroups(claims);
286
+ const duplicateIds = new Set(groups.flatMap((group) => group.duplicateIds));
287
+ const canonicalIds = new Set(groups.map((group) => group.canonicalId));
288
+ const canonicalClaims = claims.filter((claim) => !duplicateIds.has(claim.id));
289
+ const consolidated = canonicalClaims.map((claim) => ({
290
+ ...claim,
291
+ mergedClaimIds: groups.find((group) => group.canonicalId === claim.id)?.duplicateIds ?? []
292
+ }));
293
+ const report = {
294
+ schemaVersion: "soturail.brain.consolidation.v1",
295
+ createdAt: new Date().toISOString(),
296
+ dryRun,
297
+ claimsRead: claims.length,
298
+ duplicateGroups: groups.length,
299
+ mergedClaims: duplicateIds.size,
300
+ canonicalClaims: canonicalIds.size + claims.filter((claim) => !duplicateIds.has(claim.id) && !canonicalIds.has(claim.id)).length,
301
+ groups
302
+ };
303
+ await writeJson(paths.brainConsolidationReportJson, report);
304
+ await fs.writeFile(paths.brainConsolidationReportMd, renderConsolidationReport(root, report), "utf8");
305
+ await fs.writeFile(paths.brainConsolidatedClaimsFile, consolidated.map((claim) => JSON.stringify(claim)).join("\n") + (consolidated.length > 0 ? "\n" : ""), "utf8");
306
+ return {
307
+ report,
308
+ output: [
309
+ "SotuRail brain consolidate",
310
+ `dry_run: ${dryRun}`,
311
+ `claims_read: ${report.claimsRead}`,
312
+ `duplicate_groups: ${report.duplicateGroups}`,
313
+ `merged_claims: ${report.mergedClaims}`,
314
+ `canonical_claims: ${report.canonicalClaims}`,
315
+ `consolidated: ${relativeToRoot(root, paths.brainConsolidatedClaimsFile)}`,
316
+ `report_json: ${relativeToRoot(root, paths.brainConsolidationReportJson)}`,
317
+ `report: ${relativeToRoot(root, paths.brainConsolidationReportMd)}`,
318
+ "history_preserved: true"
319
+ ].join("\n") + "\n"
320
+ };
321
+ }
322
+ export async function brainDoctor(root = process.cwd(), options = {}) {
247
323
  await initBrain(root);
248
324
  const paths = getWorkspacePaths(root);
249
325
  const counts = await brainCounts(root);
@@ -263,21 +339,47 @@ export async function brainDoctor(root = process.cwd()) {
263
339
  files.push({ path: relativeToRoot(root, filePath), present: await exists(filePath) });
264
340
  }
265
341
  const claims = await readJsonl(paths.brainClaimsFile);
342
+ const rules = await readJsonl(paths.brainRulesFile);
343
+ const duplicateGroups = findDuplicateClaimGroups(claims).length;
344
+ const activeSourceIds = new Set([...claims.map((claim) => claim.id), ...(await readJsonl(paths.brainDecisionsFile)).map((decision) => decision.id)]);
345
+ const rulesWithoutSources = rules.filter((rule) => [...rule.sourceClaimIds, ...(rule.sourceDecisionIds ?? [])].every((id) => !activeSourceIds.has(id))).length;
346
+ const integrationStatus = {
347
+ projectProfile: await exists(paths.brainProjectProfileFile) ? "present" : "missing",
348
+ workflow: await exists(paths.workflowsDir) ? "present" : "missing",
349
+ harness: await exists(paths.harnessDir) ? "present" : "missing",
350
+ diagram: await exists(paths.diagramsDir) ? "present" : "missing",
351
+ eval: await exists(path.join(paths.workspace, "eval", "latest.json")) ? "latest report present" : "no latest report",
352
+ agentExports: await exists(path.join(paths.brainExportsDir, "agent-brief.md")) ? "present" : "missing",
353
+ releaseProcess: await exists(path.join(root, "docs", "releases")) ? "docs/releases present" : "unknown"
354
+ };
266
355
  const findings = [
267
356
  ...(claims.some((claim) => !claim.sourcePath) ? ["Some claims are missing source paths."] : []),
268
357
  ...(claims.some((claim) => claim.status === "verified" && claim.validatedBy.length === 0) ? ["Some verified claims have no validation references."] : []),
358
+ ...(duplicateGroups > 0 ? [`Duplicate claim groups found: ${duplicateGroups}`] : []),
269
359
  ...(counts.gaps > 0 ? [`Open gaps recorded: ${counts.gaps}`] : []),
270
360
  ...(counts.suspectOrStale > 0 ? [`Suspect or stale records/events: ${counts.suspectOrStale}`] : []),
361
+ ...(rulesWithoutSources > 0 ? [`Rules without live brain sources: ${rulesWithoutSources}`] : []),
271
362
  ...(await exists(path.join(paths.brainExportsDir, "agent-brief.md")) ? [] : ["Agent brief export is missing."])
272
363
  ];
364
+ const repairPlan = options.repairPlan ? (await staleBrain(root, { repairPlan: true })).repairPlan : undefined;
273
365
  const report = {
274
366
  schemaVersion: "soturail.brain.doctor.v1",
275
367
  ok: files.every((file) => file.present && file.validJsonl !== false),
276
368
  createdAt: new Date().toISOString(),
277
369
  files,
278
370
  counts,
371
+ duplicateClaimGroups: duplicateGroups,
372
+ integrationStatus,
373
+ ...(repairPlan ? { repairPlanPath: relativeToRoot(root, paths.brainRepairPlanMd) } : {}),
279
374
  findings,
280
- nextCommands: ["soturail brain scan", "soturail brain stale", "soturail brain export --agent generic", "soturail rules from-brain"]
375
+ nextCommands: [
376
+ "soturail brain scan",
377
+ "soturail brain consolidate --dry-run",
378
+ "soturail brain stale --repair-plan",
379
+ "soturail brain export --agent generic",
380
+ "soturail rules from-brain",
381
+ "soturail eval run --suite brain"
382
+ ]
281
383
  };
282
384
  await writeJson(paths.brainDoctorFile, report);
283
385
  return {
@@ -291,11 +393,17 @@ export async function brainDoctor(root = process.cwd()) {
291
393
  `gaps: ${counts.gaps}`,
292
394
  `rules: ${counts.rules}`,
293
395
  `stale_events: ${counts.staleEvents}`,
396
+ `duplicate_claim_groups: ${duplicateGroups}`,
397
+ `rules_without_sources: ${rulesWithoutSources}`,
294
398
  `doctor_json: ${relativeToRoot(root, paths.brainDoctorFile)}`,
399
+ ...(repairPlan ? [`repair_plan: ${relativeToRoot(root, paths.brainRepairPlanMd)}`] : []),
295
400
  "",
296
401
  "Files:",
297
402
  ...files.map((file) => `- ${file.path}: ${file.present ? "present" : "missing"}${file.validJsonl === undefined ? "" : `, jsonl_valid=${file.validJsonl}`}`),
298
403
  "",
404
+ "Integration status:",
405
+ ...Object.entries(integrationStatus).map(([key, value]) => `- ${key}: ${value}`),
406
+ "",
299
407
  "Findings:",
300
408
  ...(findings.length > 0 ? findings.map((finding) => `- ${finding}`) : ["- none"]),
301
409
  "",
@@ -304,7 +412,7 @@ export async function brainDoctor(root = process.cwd()) {
304
412
  ].join("\n") + "\n"
305
413
  };
306
414
  }
307
- export async function exportBrain(agent, root = process.cwd()) {
415
+ export async function exportBrain(agent, root = process.cwd(), options = {}) {
308
416
  await ensureProfile(root);
309
417
  const paths = getWorkspacePaths(root);
310
418
  const profile = JSON.parse(await fs.readFile(paths.brainProjectProfileFile, "utf8"));
@@ -315,7 +423,7 @@ export async function exportBrain(agent, root = process.cwd()) {
315
423
  const rules = await readJsonl(paths.brainRulesFile);
316
424
  const freshness = await readJson(paths.brainFreshnessFile).catch(() => null);
317
425
  const approvedMemory = await readJsonl(paths.memoryApprovedFile).catch(() => []);
318
- const base = renderBrainBrief(agent, profile, claims, decisions, gaps, bugs, rules, freshness, approvedMemory);
426
+ const base = renderBrainBrief(agent, profile, claims, decisions, gaps, bugs, rules, freshness, approvedMemory, options);
319
427
  const content = agent === "claude" ? `<project_brain>\n${base}\n</project_brain>\n` : base;
320
428
  const outputPath = path.join(paths.brainExportsDir, `${agent}.md`);
321
429
  await fs.writeFile(outputPath, content, "utf8");
@@ -328,6 +436,8 @@ export async function exportBrain(agent, root = process.cwd()) {
328
436
  "SotuRail brain export",
329
437
  `agent: ${agent}`,
330
438
  `path: ${relativeToRoot(root, outputPath)}`,
439
+ `limit: ${normalizeLimit(options.limit)}`,
440
+ `include_suspect: ${options.includeSuspect === true}`,
331
441
  ...(agent === "generic" ? [`generic_alias: ${relativeToRoot(root, path.join(paths.brainExportsDir, "agent-brief.md"))}`] : []),
332
442
  "review_required: true"
333
443
  ].join("\n") + "\n"
@@ -338,19 +448,24 @@ export async function rulesFromBrain(root = process.cwd()) {
338
448
  const paths = getWorkspacePaths(root);
339
449
  const claims = await readJsonl(paths.brainClaimsFile);
340
450
  const decisions = await readJsonl(paths.brainDecisionsFile);
451
+ const staleEvents = await readJsonl(paths.brainStaleEventsFile);
452
+ const unsafeClaimIds = new Set(staleEvents.filter((event) => event.status === "suspect" || event.status === "stale").map((event) => event.recordId));
341
453
  const now = new Date().toISOString();
342
454
  const rules = [];
343
- for (const claim of claims.filter((item) => item.status === "verified").slice(0, 25)) {
455
+ for (const claim of claims.filter((item) => item.status !== "rejected" && item.status !== "stale" && item.status !== "suspect").slice(0, 25)) {
456
+ if (unsafeClaimIds.has(claim.id))
457
+ continue;
344
458
  const ruleText = ruleFromClaim(claim.claim);
345
459
  if (!ruleText)
346
460
  continue;
461
+ const verified = claim.status === "verified";
347
462
  rules.push({
348
463
  schemaVersion: "soturail.brain.rule.v1",
349
464
  id: makeRailId("rule", `${claim.id}:${ruleText}`),
350
465
  rule: ruleText,
351
466
  sourceClaimIds: [claim.id],
352
- enforcement: claim.kind === "release" || claim.kind === "security" || claim.kind === "policy" ? "manual-review" : "advisory",
353
- status: "active",
467
+ enforcement: ruleEnforcementForClaim(claim),
468
+ status: verified ? "active" : "draft",
354
469
  createdAt: now
355
470
  });
356
471
  }
@@ -369,22 +484,26 @@ export async function rulesFromBrain(root = process.cwd()) {
369
484
  await appendRecordsIfNew(paths.brainRulesFile, rules);
370
485
  await fs.mkdir(paths.rulesDir, { recursive: true });
371
486
  const markdownPath = path.join(paths.rulesDir, "from-brain.md");
487
+ const jsonPath = path.join(paths.rulesDir, "from-brain.json");
372
488
  await fs.writeFile(markdownPath, [
373
489
  "# Brain-Derived Rules",
374
490
  "",
375
- "These rules are derived from verified Project Brain claims and active decisions. Review before using as policy gates.",
491
+ "These rules are derived from verified Project Brain claims and active decisions. Suspect or stale claims are excluded from active rules by default.",
376
492
  "",
377
- ...rules.map((rule) => `- ${rule.rule} (sources: ${[...rule.sourceClaimIds, ...(rule.sourceDecisionIds ?? [])].join(", ") || "none"})`),
493
+ ...rules.map((rule) => `- [${rule.status}/${rule.enforcement}] ${rule.rule} (sources: ${[...rule.sourceClaimIds, ...(rule.sourceDecisionIds ?? [])].join(", ") || "none"})`),
378
494
  ""
379
495
  ].join("\n"), "utf8");
496
+ await writeJson(jsonPath, { schemaVersion: "soturail.rules.from-brain.v1", createdAt: now, rules });
380
497
  return {
381
498
  rules,
382
499
  markdownPath,
500
+ jsonPath,
383
501
  output: [
384
502
  "SotuRail rules from-brain",
385
503
  `rules_written: ${rules.length}`,
386
504
  `jsonl: ${relativeToRoot(root, paths.brainRulesFile)}`,
387
505
  `markdown: ${relativeToRoot(root, markdownPath)}`,
506
+ `json: ${relativeToRoot(root, jsonPath)}`,
388
507
  "review_required: true"
389
508
  ].join("\n") + "\n"
390
509
  };
@@ -468,7 +587,7 @@ async function brainCounts(root) {
468
587
  gaps: gaps.filter((gap) => gap.status === "open").length,
469
588
  rules: (await readJsonl(paths.brainRulesFile)).length,
470
589
  staleEvents: staleEvents.length,
471
- suspectOrStale: claims.filter((claim) => claim.status === "suspect" || claim.status === "stale").length + staleEvents.filter((event) => event.status !== "resolved").length
590
+ suspectOrStale: claims.filter((claim) => claim.status === "suspect" || claim.status === "stale").length + staleEvents.filter((event) => event.status === "suspect" || event.status === "stale").length
472
591
  };
473
592
  }
474
593
  function defaultProfile(root, counts, now) {
@@ -508,7 +627,9 @@ function defaultArchitecture(root, now) {
508
627
  ".soturail/brain/architecture.json",
509
628
  ".soturail/brain/brain-index.json",
510
629
  ".soturail/brain/freshness.json",
511
- ".soturail/brain/doctor.json"
630
+ ".soturail/brain/doctor.json",
631
+ ".soturail/brain/consolidation-report.json",
632
+ ".soturail/brain/stale-repair-plan.json"
512
633
  ],
513
634
  exports: [relativeToRoot(root, path.join(getWorkspacePaths(root).brainExportsDir, "agent-brief.md"))]
514
635
  },
@@ -546,18 +667,252 @@ async function sourceEvidence(root, relativePath, needle, forcedRange) {
546
667
  rangeHash: `sha256-${sha256Text(rangeText)}`
547
668
  };
548
669
  }
549
- function staleEvent(recordId, reason, previousHash, currentHash, status) {
670
+ function staleEvent(recordId, reason, previousHash, currentHash, status, metadata = {}) {
550
671
  return {
551
672
  schemaVersion: "soturail.brain.stale-event.v1",
552
- id: makeRailId("stale", `${recordId}:${reason}:${previousHash}:${currentHash}`),
673
+ id: makeRailId("stale", `${recordId}:${reason}:${previousHash}:${currentHash}:${metadata.candidateRange?.startLine ?? ""}`),
553
674
  recordId,
554
675
  reason,
555
676
  previousHash,
556
677
  currentHash,
557
678
  status,
679
+ ...metadata,
558
680
  createdAt: new Date().toISOString()
559
681
  };
560
682
  }
683
+ async function relocateSourceRange(root, claim) {
684
+ const absolute = path.resolve(root, claim.sourcePath);
685
+ const raw = await fs.readFile(absolute, "utf8").catch(() => "");
686
+ if (!raw)
687
+ return { status: "missing", similarity: 0, rangeHash: "sha256-missing" };
688
+ const lines = raw.split(/\r?\n/);
689
+ const windowSize = Math.max(1, Math.min(12, claim.sourceRange.endLine - claim.sourceRange.startLine + 1));
690
+ const queryTokens = tokenSet(`${claim.claim} ${claim.kind} ${claim.tags.join(" ")}`);
691
+ const preferredStart = Math.max(0, claim.sourceRange.startLine - 1);
692
+ let best = null;
693
+ for (let index = 0; index < lines.length; index += 1) {
694
+ const window = lines.slice(index, Math.min(lines.length, index + windowSize)).join("\n");
695
+ const similarity = sourceSimilarity(queryTokens, window, claim.claim, index, preferredStart);
696
+ if (!best || similarity > best.similarity) {
697
+ best = { range: { startLine: index + 1, endLine: Math.min(lines.length, index + windowSize) }, similarity, text: window };
698
+ }
699
+ }
700
+ if (!best)
701
+ return { status: "missing", similarity: 0, rangeHash: "sha256-missing" };
702
+ const rounded = Number(best.similarity.toFixed(3));
703
+ if (rounded >= 0.62) {
704
+ return { status: "relocated", range: best.range, similarity: rounded, rangeHash: `sha256-${sha256Text(best.text)}` };
705
+ }
706
+ if (rounded >= 0.35) {
707
+ return { status: "candidate", range: best.range, similarity: rounded, rangeHash: `sha256-${sha256Text(best.text)}` };
708
+ }
709
+ return { status: "missing", similarity: rounded, rangeHash: `sha256-${sha256Text(best.text)}` };
710
+ }
711
+ function sourceSimilarity(queryTokens, window, claim, index, preferredStart) {
712
+ const windowTokens = tokenSet(window);
713
+ if (windowTokens.size === 0)
714
+ return 0;
715
+ const overlap = [...queryTokens].filter((token) => windowTokens.has(token)).length;
716
+ const union = new Set([...queryTokens, ...windowTokens]).size || 1;
717
+ const jaccard = overlap / union;
718
+ const claimWords = [...tokenSet(claim)].slice(0, 8);
719
+ const phraseBoost = claimWords.length > 0 && claimWords.every((word) => windowTokens.has(word)) ? 0.28 : 0;
720
+ const nearbyBoost = Math.abs(index - preferredStart) <= 8 ? 0.08 : 0;
721
+ return Math.min(1, jaccard + phraseBoost + nearbyBoost);
722
+ }
723
+ function tokenSet(value) {
724
+ return new Set(value.toLowerCase().replace(/[^a-z0-9_./-]+/g, " ").split(/\s+/).filter((token) => token.length > 2));
725
+ }
726
+ async function writeRepairPlan(root, claims, events) {
727
+ const paths = getWorkspacePaths(root);
728
+ const claimById = new Map(claims.map((claim) => [claim.id, claim]));
729
+ const suggestions = events
730
+ .filter((event) => event.status === "suspect" || event.status === "stale" || event.status === "relocated")
731
+ .map((event) => {
732
+ const claim = claimById.get(event.recordId);
733
+ const sourcePath = claim?.sourcePath ?? "unknown";
734
+ const text = claim?.claim ?? event.recordId;
735
+ const relatedValidation = claim?.validatedBy ?? [];
736
+ const affectedRange = event.previousRange ?? claim?.sourceRange;
737
+ const action = event.status === "relocated"
738
+ ? "Confirm the relocated range still proves the claim, then rescan/reverse-claim the source if the new range should become canonical."
739
+ : event.status === "stale"
740
+ ? "Restore or replace the missing source evidence, then rerun brain scan and stale checks."
741
+ : "Inspect the source range and validation references before trusting or exporting this claim.";
742
+ const suggestion = {
743
+ recordId: event.recordId,
744
+ type: "claim",
745
+ text,
746
+ reason: event.reason,
747
+ sourcePath,
748
+ recommendedCommand: sourcePath === "unknown" ? "soturail brain scan" : `soturail reverse claims ${sourcePath}`,
749
+ recommendedHumanAction: action,
750
+ relatedValidation
751
+ };
752
+ if (affectedRange)
753
+ suggestion.affectedRange = affectedRange;
754
+ if (event.candidateRange)
755
+ suggestion.candidateRange = event.candidateRange;
756
+ if (event.similarity !== undefined)
757
+ suggestion.similarity = event.similarity;
758
+ return suggestion;
759
+ });
760
+ const plan = {
761
+ schemaVersion: "soturail.brain.repair-plan.v1",
762
+ createdAt: new Date().toISOString(),
763
+ suggestions
764
+ };
765
+ await writeJson(paths.brainRepairPlanJson, plan);
766
+ await fs.writeFile(paths.brainRepairPlanMd, renderRepairPlan(root, plan), "utf8");
767
+ return plan;
768
+ }
769
+ function renderRepairPlan(root, plan) {
770
+ return [
771
+ "# SotuRail Brain Stale Repair Plan",
772
+ "",
773
+ "This plan is guidance only. SotuRail does not auto-edit code, docs or claims.",
774
+ "",
775
+ `createdAt: ${plan.createdAt}`,
776
+ `suggestions: ${plan.suggestions.length}`,
777
+ "",
778
+ ...(plan.suggestions.length > 0 ? plan.suggestions.flatMap((item) => [
779
+ `## ${item.recordId}`,
780
+ "",
781
+ `- type: ${item.type}`,
782
+ `- reason: ${item.reason}`,
783
+ `- source: ${item.sourcePath}`,
784
+ `- affected_range: ${formatRange(item.affectedRange)}`,
785
+ `- candidate_range: ${formatRange(item.candidateRange)}`,
786
+ `- similarity: ${item.similarity ?? "n/a"}`,
787
+ `- recommended_command: \`${item.recommendedCommand}\``,
788
+ `- human_action: ${item.recommendedHumanAction}`,
789
+ `- related_validation: ${item.relatedValidation.length > 0 ? item.relatedValidation.join(", ") : "none"}`,
790
+ "",
791
+ summarizeText(item.text, 240),
792
+ ""
793
+ ]) : [
794
+ "No stale, suspect or relocated records need repair guidance.",
795
+ ""
796
+ ]),
797
+ "## Safe Follow-up",
798
+ "",
799
+ "- Run `soturail brain stale --repair-plan` after source changes.",
800
+ "- Run `soturail reverse claims ./src` after confirming source changes.",
801
+ "- Run `soturail brain consolidate --dry-run` before deriving new rules.",
802
+ "- Run `soturail eval run --suite brain` before release.",
803
+ "",
804
+ `json: ${relativeToRoot(root, getWorkspacePaths(root).brainRepairPlanJson)}`,
805
+ ""
806
+ ].join("\n");
807
+ }
808
+ function formatRange(range) {
809
+ return range ? `${range.startLine}-${range.endLine}` : "n/a";
810
+ }
811
+ function findDuplicateClaimGroups(claims) {
812
+ const groups = [];
813
+ const consumed = new Set();
814
+ const candidates = claims.filter((claim) => claim.status !== "rejected");
815
+ for (const claim of candidates) {
816
+ if (consumed.has(claim.id))
817
+ continue;
818
+ const matches = candidates.filter((candidate) => candidate.id !== claim.id && !consumed.has(candidate.id) && compatibleClaims(claim, candidate));
819
+ if (matches.length === 0)
820
+ continue;
821
+ const members = [claim, ...matches].sort(compareCanonicalClaim);
822
+ const canonical = members[0] ?? claim;
823
+ for (const member of members)
824
+ consumed.add(member.id);
825
+ const duplicateMembers = members.filter((member) => member.id !== canonical.id);
826
+ groups.push({
827
+ canonicalId: canonical.id,
828
+ duplicateIds: duplicateMembers.map((member) => member.id),
829
+ reason: duplicateMembers.every((member) => normalizeClaimText(member.claim) === normalizeClaimText(canonical.claim))
830
+ ? "normalized claim text match"
831
+ : "near-duplicate claim text with compatible kind/source",
832
+ score: Math.max(...duplicateMembers.map((member) => claimSimilarity(canonical, member))),
833
+ claims: members.map((member) => ({
834
+ id: member.id,
835
+ status: member.status,
836
+ confidence: member.confidence,
837
+ sourcePath: member.sourcePath,
838
+ claim: member.claim
839
+ }))
840
+ });
841
+ }
842
+ return groups.sort((left, right) => left.canonicalId.localeCompare(right.canonicalId));
843
+ }
844
+ function compatibleClaims(left, right) {
845
+ if (left.kind !== right.kind)
846
+ return false;
847
+ if (left.status === "rejected" || right.status === "rejected")
848
+ return false;
849
+ if (left.status === "stale" && right.status === "verified")
850
+ return false;
851
+ if (right.status === "stale" && left.status === "verified")
852
+ return false;
853
+ const normalizedLeft = normalizeClaimText(left.claim);
854
+ const normalizedRight = normalizeClaimText(right.claim);
855
+ if (normalizedLeft === normalizedRight)
856
+ return true;
857
+ if (left.sourcePath !== right.sourcePath && tagOverlap(left.tags, right.tags) === 0)
858
+ return false;
859
+ return claimSimilarity(left, right) >= 0.82;
860
+ }
861
+ function normalizeClaimText(value) {
862
+ return value.toLowerCase().replace(/[`"'.,:;!?()[\]{}]/g, " ").replace(/\s+/g, " ").trim();
863
+ }
864
+ function claimSimilarity(left, right) {
865
+ const leftTokens = tokenSet(left.claim);
866
+ const rightTokens = tokenSet(right.claim);
867
+ const overlap = [...leftTokens].filter((token) => rightTokens.has(token)).length;
868
+ const union = new Set([...leftTokens, ...rightTokens]).size || 1;
869
+ const tagBonus = tagOverlap(left.tags, right.tags) > 0 ? 0.08 : 0;
870
+ const sourceBonus = left.sourcePath === right.sourcePath ? 0.08 : 0;
871
+ return Math.min(1, overlap / union + tagBonus + sourceBonus);
872
+ }
873
+ function tagOverlap(left, right) {
874
+ const rightSet = new Set(right);
875
+ return left.filter((tag) => rightSet.has(tag)).length;
876
+ }
877
+ function compareCanonicalClaim(left, right) {
878
+ return canonicalClaimScore(right) - canonicalClaimScore(left) || left.id.localeCompare(right.id);
879
+ }
880
+ function canonicalClaimScore(claim) {
881
+ const status = claim.status === "verified" ? 8 : claim.status === "unverified" ? 4 : claim.status === "suspect" ? 2 : 0;
882
+ const confidence = claim.confidence === "high" ? 3 : claim.confidence === "medium" ? 1 : 0;
883
+ const validation = claim.validatedBy.length > 0 ? 3 : 0;
884
+ return status + confidence + validation;
885
+ }
886
+ function renderConsolidationReport(root, report) {
887
+ return [
888
+ "# SotuRail Brain Claim Consolidation Report",
889
+ "",
890
+ "This report preserves append-only history. It writes a consolidated view; it does not delete original claim records.",
891
+ "",
892
+ `createdAt: ${report.createdAt}`,
893
+ `dry_run: ${report.dryRun}`,
894
+ `claims_read: ${report.claimsRead}`,
895
+ `duplicate_groups: ${report.duplicateGroups}`,
896
+ `merged_claims: ${report.mergedClaims}`,
897
+ `canonical_claims: ${report.canonicalClaims}`,
898
+ "",
899
+ ...(report.groups.length > 0 ? report.groups.flatMap((group) => [
900
+ `## ${group.canonicalId}`,
901
+ "",
902
+ `- reason: ${group.reason}`,
903
+ `- score: ${group.score.toFixed(3)}`,
904
+ `- duplicates: ${group.duplicateIds.join(", ")}`,
905
+ "",
906
+ ...group.claims.map((claim) => `- ${claim.id} [${claim.status}/${claim.confidence}] ${claim.claim} (${claim.sourcePath})`),
907
+ ""
908
+ ]) : [
909
+ "No duplicate claim groups found.",
910
+ ""
911
+ ]),
912
+ `json: ${relativeToRoot(root, getWorkspacePaths(root).brainConsolidationReportJson)}`,
913
+ ""
914
+ ].join("\n");
915
+ }
561
916
  async function allRecallRecords(root) {
562
917
  const paths = getWorkspacePaths(root);
563
918
  return [
@@ -589,13 +944,29 @@ function confidenceScore(confidence) {
589
944
  return 1;
590
945
  return 0;
591
946
  }
592
- function renderBrainBrief(agent, profile, claims, decisions, gaps, bugs, rules, freshness, approvedMemory) {
947
+ function renderBrainBrief(agent, profile, claims, decisions, gaps, bugs, rules, freshness, approvedMemory, options = {}) {
593
948
  const capability = getAgentCapability(agent);
594
- const verifiedClaims = claims.filter((claim) => claim.status === "verified").slice(0, 12);
595
- return [
949
+ const limit = normalizeLimit(options.limit);
950
+ const eventByRecord = new Map((freshness?.events ?? []).map((event) => [event.recordId, event]));
951
+ const verifiedClaims = claims.filter((claim) => claim.status === "verified" && !hasUnsafeEvent(eventByRecord.get(claim.id))).slice(0, limit);
952
+ const suspectClaims = claims.filter((claim) => claim.status === "suspect" || eventByRecord.get(claim.id)?.status === "suspect").slice(0, limit);
953
+ const staleClaims = claims.filter((claim) => claim.status === "stale" || eventByRecord.get(claim.id)?.status === "stale").slice(0, limit);
954
+ const verifiedRules = rules.filter((rule) => rule.status === "active" && rule.sourceClaimIds.every((id) => !hasUnsafeEvent(eventByRecord.get(id)))).slice(0, limit);
955
+ const activeDecisions = decisions.filter((decision) => decision.status === "active").slice(0, limit);
956
+ const openGaps = gaps.filter((gap) => gap.status === "open").slice(0, limit);
957
+ const recurringBugs = bugs.slice(0, limit);
958
+ const warnings = [
959
+ ...(freshness?.events ?? []).filter((event) => event.status === "suspect" || event.status === "stale").slice(0, limit).map((event) => `- ${event.recordId}: ${event.reason} [${event.status}]`),
960
+ ...(options.includeSuspect === true ? suspectClaims.map((claim) => `- ${claim.id}: ${claim.claim} [${claim.status}]`) : [])
961
+ ];
962
+ const incomplete = claims.length === 0 || profile.releaseNotesPath === "unknown";
963
+ const hostNote = hostBrainBriefNote(agent);
964
+ const lines = [
596
965
  `# SotuRail Project Brain Brief (${agent})`,
597
966
  "",
598
967
  "This brief is evidence-backed local context. Do not overclaim beyond the sources listed here.",
968
+ hostNote,
969
+ ...(incomplete ? ["", "Data completeness: incomplete brain data. Run `soturail brain scan` and `soturail brain stale --repair-plan`."] : []),
599
970
  "",
600
971
  "## Project Identity",
601
972
  "",
@@ -605,34 +976,60 @@ function renderBrainBrief(agent, profile, claims, decisions, gaps, bugs, rules,
605
976
  `- identity: ${profile.identity}`,
606
977
  `- recommended_payloads: ${capability.recommendedPayloads.join(" + ")}`,
607
978
  "",
979
+ "## Verified Rules",
980
+ "",
981
+ ...nonEmpty(verifiedRules.map((rule) => `- ${rule.rule} (${rule.id}, enforcement: ${rule.enforcement}, sources: ${[...rule.sourceClaimIds, ...(rule.sourceDecisionIds ?? [])].join(", ") || "none"})`), "- none yet"),
982
+ "",
608
983
  "## Verified Claims",
609
984
  "",
610
985
  ...(verifiedClaims.length > 0 ? verifiedClaims.map((claim) => `- ${claim.claim} (${claim.id}, source: ${claim.sourcePath}:${claim.sourceRange.startLine})`) : ["- none yet"]),
611
986
  "",
612
987
  "## Active Rules",
613
988
  "",
614
- ...nonEmpty(rules.filter((rule) => rule.status === "active").slice(0, 12).map((rule) => `- ${rule.rule} (sources: ${rule.sourceClaimIds.join(", ") || (rule.sourceDecisionIds ?? []).join(", ") || "none"})`), "- none yet"),
989
+ ...nonEmpty(verifiedRules.map((rule) => `- ${rule.rule} (sources: ${rule.sourceClaimIds.join(", ") || (rule.sourceDecisionIds ?? []).join(", ") || "none"})`), "- none yet"),
990
+ "",
991
+ "## Current Release Process",
992
+ "",
993
+ ...nonEmpty(profile.knownReleaseProcess.slice(0, limit).map((step) => `- ${step}`), "- no release process recorded yet"),
615
994
  "",
616
995
  "## Active Decisions",
617
996
  "",
618
- ...nonEmpty(decisions.filter((decision) => decision.status === "active").slice(0, 8).map((decision) => `- ${decision.title}: ${decision.decision} (source: ${decision.sourcePath})`), "- none yet"),
997
+ ...nonEmpty(activeDecisions.map((decision) => `- ${decision.title}: ${decision.decision} (source: ${decision.sourcePath})`), "- none yet"),
619
998
  "",
620
999
  "## Known Gaps",
621
1000
  "",
622
- ...nonEmpty(gaps.filter((gap) => gap.status === "open").slice(0, 8).map((gap) => `- [${gap.severity}] ${gap.gap} (source: ${gap.sourcePath})`), "- none recorded"),
1001
+ ...nonEmpty(openGaps.map((gap) => `- [${gap.severity}] ${gap.gap} (source: ${gap.sourcePath})`), "- none recorded"),
623
1002
  "",
624
- "## Recurring Bugs And Harness Notes",
1003
+ "## Recurring Bugs / Harness Patterns",
625
1004
  "",
626
- ...nonEmpty(bugs.slice(0, 8).map((bug) => `- ${bug.bug} (source: ${bug.sourcePath})`), "- none recorded"),
1005
+ ...nonEmpty(recurringBugs.map((bug) => `- ${bug.bug} (source: ${bug.sourcePath})`), "- none recorded"),
1006
+ "",
1007
+ "## Suspect Claims",
1008
+ "",
1009
+ ...nonEmpty(suspectClaims.map((claim) => `- ${claim.claim} (${claim.id}, source: ${claim.sourcePath}:${claim.sourceRange.startLine})`), "- none from latest data"),
1010
+ "",
1011
+ "## Stale Claims",
1012
+ "",
1013
+ ...nonEmpty(staleClaims.map((claim) => `- ${claim.claim} (${claim.id}, source: ${claim.sourcePath}:${claim.sourceRange.startLine})`), "- none from latest data"),
627
1014
  "",
628
1015
  "## Safe Commands",
629
1016
  "",
1017
+ "- soturail brain scan",
1018
+ "- soturail brain consolidate --dry-run",
1019
+ "- soturail brain stale --repair-plan",
630
1020
  "- soturail brain doctor",
631
1021
  "- soturail context budget --explain",
632
1022
  "- soturail workflow verify",
633
1023
  "- soturail eval run --suite brain",
634
1024
  "- npm run release:check",
635
1025
  "",
1026
+ "## Critical Commands",
1027
+ "",
1028
+ "- npm run typecheck",
1029
+ "- npm run build",
1030
+ "- npm test",
1031
+ "- npm run release:check",
1032
+ "",
636
1033
  "## Release And Workflow Notes",
637
1034
  "",
638
1035
  `- release_notes_path: ${profile.releaseNotesPath}`,
@@ -641,13 +1038,32 @@ function renderBrainBrief(agent, profile, claims, decisions, gaps, bugs, rules,
641
1038
  `- diagram: ${profile.workflowHarnessDiagramEval.diagram ?? "unknown"}`,
642
1039
  `- eval: ${profile.workflowHarnessDiagramEval.eval ?? "unknown"}`,
643
1040
  "",
1041
+ "## Recovery Pointers",
1042
+ "",
1043
+ "- Brain profile: `.soturail/brain/project-profile.json`",
1044
+ "- Brain freshness: `.soturail/brain/freshness.json`",
1045
+ "- Brain repair plan: `.soturail/brain/stale-repair-plan.md` when generated",
1046
+ "- Workflow evidence: `soturail workflow evidence <id>`",
1047
+ "- Raw logs: `soturail expand <raw_id>` with redaction by default",
1048
+ "",
644
1049
  "## Approved Memory Summary",
645
1050
  "",
646
- ...(approvedMemory.length > 0 ? approvedMemory.slice(0, 5).map((memory) => `- ${memory.text} (${memory.source})`) : ["- no approved memory exported"]),
1051
+ ...(approvedMemory.length > 0 ? approvedMemory.slice(0, Math.min(limit, 5)).map((memory) => `- ${memory.text} (${memory.source})`) : ["- no approved memory exported"]),
647
1052
  "",
648
1053
  "## Stale Or Suspect Warnings",
649
1054
  "",
650
- ...(freshness && (freshness.suspect > 0 || freshness.stale > 0) ? freshness.events.slice(0, 8).map((event) => `- ${event.recordId}: ${event.reason} [${event.status}]`) : ["- none from latest freshness check"]),
1055
+ ...(warnings.length > 0 ? warnings : ["- none from latest freshness check"]),
1056
+ "",
1057
+ "## Do Not Do",
1058
+ "",
1059
+ "- Do not treat stale or suspect claims as verified.",
1060
+ "- Do not expose private memory, secrets or raw logs in agent prompts.",
1061
+ "- Do not publish or create releases without release gates.",
1062
+ "- Do not assume Project Brain is a full static analyzer or LLM reviewer.",
1063
+ "",
1064
+ "## Source References",
1065
+ "",
1066
+ ...nonEmpty(verifiedClaims.map((claim) => `- ${claim.id}: ${claim.sourcePath}:${claim.sourceRange.startLine}`), "- none yet"),
651
1067
  "",
652
1068
  "## Safety Notes",
653
1069
  "",
@@ -656,7 +1072,27 @@ function renderBrainBrief(agent, profile, claims, decisions, gaps, bugs, rules,
656
1072
  "- Brain claims are only as fresh as their source hashes.",
657
1073
  "- Generated exports should be reviewed before agent handoff.",
658
1074
  ""
659
- ].join("\n");
1075
+ ];
1076
+ return lines.join("\n");
1077
+ }
1078
+ function normalizeLimit(limit) {
1079
+ if (typeof limit !== "number" || !Number.isFinite(limit))
1080
+ return 10;
1081
+ return Math.max(1, Math.min(50, Math.trunc(limit ?? 10)));
1082
+ }
1083
+ function hasUnsafeEvent(event) {
1084
+ return event?.status === "suspect" || event?.status === "stale";
1085
+ }
1086
+ function hostBrainBriefNote(agent) {
1087
+ if (agent === "claude")
1088
+ return "Host formatting: Markdown wrapped in XML-like tags for Claude Code prompt boundaries.";
1089
+ if (agent === "codex")
1090
+ return "Host formatting: AGENTS.md-friendly Markdown with source references and safe commands.";
1091
+ if (agent === "gemini")
1092
+ return "Host formatting: Markdown sections suitable for larger-context review.";
1093
+ if (agent === "cursor")
1094
+ return "Host formatting: short rules-friendly sections for project rules/context handoff.";
1095
+ return "Host formatting: clean Markdown for generic agents.";
660
1096
  }
661
1097
  function ruleFromClaim(claim) {
662
1098
  if (/release notes live under docs\/releases/i.test(claim))
@@ -671,6 +1107,15 @@ function ruleFromClaim(claim) {
671
1107
  return "Do not treat diagram validation as a full Mermaid parser.";
672
1108
  return `Preserve evidence for: ${claim.replace(/\.$/, "")}.`;
673
1109
  }
1110
+ function ruleEnforcementForClaim(claim) {
1111
+ if (claim.status !== "verified")
1112
+ return "advisory";
1113
+ if (claim.validatedBy.length > 0)
1114
+ return "test-backed";
1115
+ if (claim.kind === "release" || claim.kind === "security" || claim.kind === "policy")
1116
+ return "policy-gate";
1117
+ return "manual-review";
1118
+ }
674
1119
  function nonEmpty(values, fallback) {
675
1120
  return values.length > 0 ? values : [fallback];
676
1121
  }