soturail 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -10
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/brain.d.ts +2 -0
- package/dist/commands/brain.js +39 -0
- package/dist/commands/brain.js.map +1 -0
- package/dist/commands/eval.js +9 -4
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/reverse.d.ts +2 -0
- package/dist/commands/reverse.js +20 -0
- package/dist/commands/reverse.js.map +1 -0
- package/dist/commands/rules.d.ts +1 -0
- package/dist/commands/rules.js +44 -1
- package/dist/commands/rules.js.map +1 -1
- package/dist/core/agent-exporter.js +1 -0
- package/dist/core/agent-exporter.js.map +1 -1
- package/dist/core/agent-runtime.js +5 -2
- package/dist/core/agent-runtime.js.map +1 -1
- package/dist/core/config.d.ts +21 -0
- package/dist/core/config.js +32 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/evaluation-suite.d.ts +7 -3
- package/dist/core/evaluation-suite.js +252 -13
- package/dist/core/evaluation-suite.js.map +1 -1
- package/dist/core/evidence-pack.js +11 -0
- package/dist/core/evidence-pack.js.map +1 -1
- package/dist/core/harness-rail.js +2 -0
- package/dist/core/harness-rail.js.map +1 -1
- package/dist/core/project-brain.d.ts +268 -0
- package/dist/core/project-brain.js +1235 -0
- package/dist/core/project-brain.js.map +1 -0
- package/dist/core/reverse-specification.d.ts +34 -0
- package/dist/core/reverse-specification.js +361 -0
- package/dist/core/reverse-specification.js.map +1 -0
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/docs/agents.md +26 -0
- package/docs/benchmarking.md +19 -0
- package/docs/diagram-rail.md +10 -0
- package/docs/evaluation-suite.md +32 -0
- package/docs/future-rails-index.md +5 -5
- package/docs/harness-rail.md +24 -0
- package/docs/knowledge-to-rules.md +60 -14
- package/docs/memory-rail.md +13 -0
- package/docs/project-brain.md +180 -0
- package/docs/releases/README.md +2 -0
- package/docs/releases/RELEASE_NOTES_v0.8.0.md +44 -0
- package/docs/releases/RELEASE_NOTES_v0.8.1.md +42 -0
- package/docs/reverse-specification-rail.md +81 -0
- package/docs/rules.md +21 -0
- package/docs/workflow-rail.md +23 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { appendJsonl, ensureWorkspace, getWorkspacePaths, readJsonl, relativeToRoot, writeJson } from "./config.js";
|
|
6
|
+
import { getAgentCapability, listAgentCapabilities } from "./agent-runtime.js";
|
|
7
|
+
import { keywordScore, makeRailId, sha256Text, summarizeText } from "./rail-utils.js";
|
|
8
|
+
import { SOTURAIL_VERSION } from "./version.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
export async function initBrain(root = process.cwd()) {
|
|
11
|
+
await ensureWorkspace(root);
|
|
12
|
+
const paths = getWorkspacePaths(root);
|
|
13
|
+
await Promise.all([
|
|
14
|
+
fs.mkdir(paths.brainDir, { recursive: true }),
|
|
15
|
+
fs.mkdir(paths.brainExportsDir, { recursive: true }),
|
|
16
|
+
fs.mkdir(paths.brainSpecsDir, { recursive: true })
|
|
17
|
+
]);
|
|
18
|
+
await Promise.all([
|
|
19
|
+
writeFileIfMissing(paths.brainClaimsFile, ""),
|
|
20
|
+
writeFileIfMissing(paths.brainDecisionsFile, ""),
|
|
21
|
+
writeFileIfMissing(paths.brainBugsFile, ""),
|
|
22
|
+
writeFileIfMissing(paths.brainGapsFile, ""),
|
|
23
|
+
writeFileIfMissing(paths.brainRulesFile, ""),
|
|
24
|
+
writeFileIfMissing(paths.brainStaleEventsFile, "")
|
|
25
|
+
]);
|
|
26
|
+
const now = new Date().toISOString();
|
|
27
|
+
const counts = await brainCounts(root);
|
|
28
|
+
await writeJsonIfMissing(paths.brainProjectProfileFile, defaultProfile(root, counts, now));
|
|
29
|
+
await writeJsonIfMissing(paths.brainArchitectureFile, defaultArchitecture(root, now));
|
|
30
|
+
await writeJsonIfMissing(paths.brainIndexFile, { schemaVersion: "soturail.brain.index.v1", updatedAt: now, claims: [], decisions: [], gaps: [], rules: [] });
|
|
31
|
+
await writeJsonIfMissing(paths.brainFreshnessFile, { schemaVersion: "soturail.brain.freshness.v1", updatedAt: now, checkedRecords: 0, suspect: 0, stale: 0, warnings: [], events: [] });
|
|
32
|
+
const createdPaths = [
|
|
33
|
+
paths.brainDir,
|
|
34
|
+
paths.brainExportsDir,
|
|
35
|
+
paths.brainClaimsFile,
|
|
36
|
+
paths.brainDecisionsFile,
|
|
37
|
+
paths.brainBugsFile,
|
|
38
|
+
paths.brainGapsFile,
|
|
39
|
+
paths.brainRulesFile,
|
|
40
|
+
paths.brainStaleEventsFile,
|
|
41
|
+
paths.brainProjectProfileFile,
|
|
42
|
+
paths.brainArchitectureFile,
|
|
43
|
+
paths.brainIndexFile,
|
|
44
|
+
paths.brainFreshnessFile,
|
|
45
|
+
paths.brainConsolidatedClaimsFile,
|
|
46
|
+
paths.brainConsolidationReportJson,
|
|
47
|
+
paths.brainConsolidationReportMd,
|
|
48
|
+
paths.brainRepairPlanJson,
|
|
49
|
+
paths.brainRepairPlanMd
|
|
50
|
+
].map((filePath) => relativeToRoot(root, filePath));
|
|
51
|
+
return {
|
|
52
|
+
paths: createdPaths,
|
|
53
|
+
output: [
|
|
54
|
+
"SotuRail brain init",
|
|
55
|
+
`brain_dir: ${relativeToRoot(root, paths.brainDir)}`,
|
|
56
|
+
`exports_dir: ${relativeToRoot(root, paths.brainExportsDir)}`,
|
|
57
|
+
`claims: ${relativeToRoot(root, paths.brainClaimsFile)}`,
|
|
58
|
+
`profile: ${relativeToRoot(root, paths.brainProjectProfileFile)}`,
|
|
59
|
+
"next_commands:",
|
|
60
|
+
"- soturail brain scan",
|
|
61
|
+
"- soturail brain profile",
|
|
62
|
+
"- soturail brain doctor",
|
|
63
|
+
"- soturail brain export --agent codex"
|
|
64
|
+
].join("\n") + "\n"
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function scanBrain(root = process.cwd()) {
|
|
68
|
+
await initBrain(root);
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
const paths = getWorkspacePaths(root);
|
|
71
|
+
const packageJson = await readPackageJson(root);
|
|
72
|
+
const tests = await listRelativeFiles(path.join(root, "tests"), root, (file) => file.endsWith(".test.ts"));
|
|
73
|
+
const docsFolders = await listDocsFolders(root);
|
|
74
|
+
const commands = await detectCliCommands(root);
|
|
75
|
+
const rails = await detectRails(root);
|
|
76
|
+
const claims = await scanClaims(root, now);
|
|
77
|
+
const added = await appendRecordsIfNew(paths.brainClaimsFile, claims);
|
|
78
|
+
const failures = await readJsonl(paths.harnessFailuresFile);
|
|
79
|
+
await appendRecordsIfNew(paths.brainBugsFile, failures.map((failure) => ({
|
|
80
|
+
schemaVersion: "soturail.brain.bug.v1",
|
|
81
|
+
id: makeRailId("bug", `${failure.id}:${failure.whatFailed}`),
|
|
82
|
+
bug: failure.whatFailed,
|
|
83
|
+
status: "recurring",
|
|
84
|
+
sourcePath: ".soturail/harness/failures.jsonl",
|
|
85
|
+
tags: ["harness", failure.preventionCandidate.replace(/\s+/g, "-")],
|
|
86
|
+
createdAt: now
|
|
87
|
+
})));
|
|
88
|
+
const counts = await brainCounts(root);
|
|
89
|
+
const releaseNotesPath = packageJson.version ? path.join("docs", "releases", `RELEASE_NOTES_v${packageJson.version}.md`) : "unknown";
|
|
90
|
+
const profile = {
|
|
91
|
+
schemaVersion: "soturail.brain.project-profile.v1",
|
|
92
|
+
projectName: packageJson.name ?? path.basename(root),
|
|
93
|
+
packageVersion: packageJson.version ?? "unknown",
|
|
94
|
+
cliVersion: SOTURAIL_VERSION,
|
|
95
|
+
identity: "Local-first Context OS for AI coding agents.",
|
|
96
|
+
mainLanguageRuntime: ["TypeScript", "Node >=20", "optional Rust/native"],
|
|
97
|
+
docsFolders,
|
|
98
|
+
releaseNotesPath,
|
|
99
|
+
rails,
|
|
100
|
+
commands,
|
|
101
|
+
tests,
|
|
102
|
+
workflowHarnessDiagramEval: {
|
|
103
|
+
workflow: await exists(paths.workflowsDir) ? "present" : "missing",
|
|
104
|
+
harness: await exists(paths.harnessDir) ? "present" : "missing",
|
|
105
|
+
diagram: await exists(paths.diagramsDir) ? "present" : "missing",
|
|
106
|
+
eval: await exists(path.join(paths.workspace, "eval", "latest.json")) ? "latest report present" : "no latest report"
|
|
107
|
+
},
|
|
108
|
+
agentHosts: listAgentCapabilities().map((capability) => capability.id),
|
|
109
|
+
knownReleaseProcess: ["npm run typecheck", "npm run build", "npm test", "npm run release:check", "tag/release after checks", "npm publish only after release gates"],
|
|
110
|
+
counts,
|
|
111
|
+
updatedAt: now
|
|
112
|
+
};
|
|
113
|
+
await writeJson(paths.brainProjectProfileFile, profile);
|
|
114
|
+
await writeJson(paths.brainArchitectureFile, defaultArchitecture(root, now));
|
|
115
|
+
await writeBrainIndex(root);
|
|
116
|
+
return {
|
|
117
|
+
profile,
|
|
118
|
+
claimsAdded: added,
|
|
119
|
+
output: [
|
|
120
|
+
"SotuRail brain scan",
|
|
121
|
+
`project: ${profile.projectName}`,
|
|
122
|
+
`package_version: ${profile.packageVersion}`,
|
|
123
|
+
`cli_version: ${profile.cliVersion}`,
|
|
124
|
+
`claims_added: ${added}`,
|
|
125
|
+
`claims_total: ${counts.claims}`,
|
|
126
|
+
`release_notes_path: ${profile.releaseNotesPath}`,
|
|
127
|
+
`rails: ${rails.join(", ")}`,
|
|
128
|
+
`commands_detected: ${commands.length}`,
|
|
129
|
+
`tests_detected: ${tests.length}`,
|
|
130
|
+
"next_commands:",
|
|
131
|
+
"- soturail brain recall \"release notes\"",
|
|
132
|
+
"- soturail brain consolidate --dry-run",
|
|
133
|
+
"- soturail brain stale --repair-plan",
|
|
134
|
+
"- soturail rules from-brain",
|
|
135
|
+
"- soturail reverse gaps"
|
|
136
|
+
].join("\n") + "\n"
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export async function brainProfile(root = process.cwd()) {
|
|
140
|
+
await ensureProfile(root);
|
|
141
|
+
const paths = getWorkspacePaths(root);
|
|
142
|
+
const profile = JSON.parse(await fs.readFile(paths.brainProjectProfileFile, "utf8"));
|
|
143
|
+
const counts = await brainCounts(root);
|
|
144
|
+
return [
|
|
145
|
+
"SotuRail brain profile",
|
|
146
|
+
`project: ${profile.projectName}`,
|
|
147
|
+
`package_version: ${profile.packageVersion}`,
|
|
148
|
+
`cli_version: ${profile.cliVersion}`,
|
|
149
|
+
`identity: ${profile.identity}`,
|
|
150
|
+
`main_runtime: ${profile.mainLanguageRuntime.join(", ")}`,
|
|
151
|
+
`release_notes_path: ${profile.releaseNotesPath}`,
|
|
152
|
+
`rails: ${profile.rails.join(", ")}`,
|
|
153
|
+
`workflow_support: ${profile.workflowHarnessDiagramEval.workflow}`,
|
|
154
|
+
`diagram_support: ${profile.workflowHarnessDiagramEval.diagram}`,
|
|
155
|
+
`eval_support: ${profile.workflowHarnessDiagramEval.eval}`,
|
|
156
|
+
`claims: ${counts.claims}`,
|
|
157
|
+
`gaps: ${counts.gaps}`,
|
|
158
|
+
`suspect_or_stale: ${counts.suspectOrStale}`,
|
|
159
|
+
"next_commands:",
|
|
160
|
+
"- soturail brain consolidate --dry-run",
|
|
161
|
+
"- soturail brain stale --repair-plan",
|
|
162
|
+
"- soturail brain export --agent generic",
|
|
163
|
+
"- soturail reverse specs ./src"
|
|
164
|
+
].join("\n") + "\n";
|
|
165
|
+
}
|
|
166
|
+
export async function recallBrain(query, root = process.cwd(), limit = 8) {
|
|
167
|
+
await ensureProfile(root);
|
|
168
|
+
const records = await allRecallRecords(root);
|
|
169
|
+
const scored = records
|
|
170
|
+
.map((record) => {
|
|
171
|
+
const text = recallText(record);
|
|
172
|
+
const score = keywordScore(query, `${text} ${record.tags.join(" ")}`);
|
|
173
|
+
const exact = text.toLowerCase().includes(query.toLowerCase());
|
|
174
|
+
const statusBoost = statusScore(record.status);
|
|
175
|
+
const confidenceBoost = confidenceScore(record.confidence);
|
|
176
|
+
return {
|
|
177
|
+
...record,
|
|
178
|
+
score: score.score + statusBoost + confidenceBoost + (exact ? 8 : 0),
|
|
179
|
+
reason: [exact ? "exact phrase" : score.reason, `status ${record.status}`, record.confidence ? `confidence ${record.confidence}` : ""].filter(Boolean).join(", ")
|
|
180
|
+
};
|
|
181
|
+
})
|
|
182
|
+
.filter((record) => record.score > 0)
|
|
183
|
+
.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
|
|
184
|
+
.slice(0, limit);
|
|
185
|
+
return [
|
|
186
|
+
"SotuRail brain recall",
|
|
187
|
+
`query: ${query}`,
|
|
188
|
+
`matches_count: ${scored.length}`,
|
|
189
|
+
"",
|
|
190
|
+
...scored.flatMap((record) => [
|
|
191
|
+
`- ${record.id} [${record.type}] score ${record.score.toFixed(2)}`,
|
|
192
|
+
` Status/confidence: ${record.status}${record.confidence ? ` / ${record.confidence}` : ""}`,
|
|
193
|
+
` Source: ${record.sourcePath || "unknown"}`,
|
|
194
|
+
` Reason: ${record.reason}`,
|
|
195
|
+
` Text: ${summarizeText(recallText(record), 220)}`,
|
|
196
|
+
""
|
|
197
|
+
])
|
|
198
|
+
].join("\n").trimEnd() + "\n";
|
|
199
|
+
}
|
|
200
|
+
export async function staleBrain(root = process.cwd(), options = {}) {
|
|
201
|
+
await initBrain(root);
|
|
202
|
+
const paths = getWorkspacePaths(root);
|
|
203
|
+
const claims = await readJsonl(paths.brainClaimsFile);
|
|
204
|
+
const existingEvents = await readJsonl(paths.brainStaleEventsFile);
|
|
205
|
+
const warnings = [];
|
|
206
|
+
const events = [];
|
|
207
|
+
for (const claim of claims) {
|
|
208
|
+
const absolute = path.resolve(root, claim.sourcePath);
|
|
209
|
+
if (!(await exists(absolute))) {
|
|
210
|
+
events.push(staleEvent(claim.id, "source file missing", claim.rangeHash, "missing", "stale", { previousRange: claim.sourceRange }));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const evidence = await sourceEvidence(root, claim.sourcePath, undefined, claim.sourceRange);
|
|
214
|
+
if (claim.rangeHash !== evidence.rangeHash) {
|
|
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
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (claim.fileHash !== evidence.fileHash) {
|
|
237
|
+
warnings.push(`${claim.id}: fileHash changed but rangeHash is unchanged`);
|
|
238
|
+
}
|
|
239
|
+
for (const validationPath of claim.validatedBy) {
|
|
240
|
+
if (!(await exists(path.resolve(root, validationPath)))) {
|
|
241
|
+
events.push(staleEvent(claim.id, `validatedBy missing: ${validationPath}`, claim.rangeHash, "missing-validation", "suspect", { previousRange: claim.sourceRange }));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const newEvents = events.filter((event) => !existingEvents.some((existing) => existing.id === event.id));
|
|
246
|
+
for (const event of newEvents)
|
|
247
|
+
await appendJsonl(paths.brainStaleEventsFile, event);
|
|
248
|
+
const freshness = {
|
|
249
|
+
schemaVersion: "soturail.brain.freshness.v1",
|
|
250
|
+
updatedAt: new Date().toISOString(),
|
|
251
|
+
checkedRecords: claims.length,
|
|
252
|
+
suspect: events.filter((event) => event.status === "suspect").length,
|
|
253
|
+
stale: events.filter((event) => event.status === "stale").length,
|
|
254
|
+
warnings,
|
|
255
|
+
events
|
|
256
|
+
};
|
|
257
|
+
await writeJson(paths.brainFreshnessFile, freshness);
|
|
258
|
+
const repairPlan = options.repairPlan ? await writeRepairPlan(root, claims, events) : undefined;
|
|
259
|
+
return {
|
|
260
|
+
freshness,
|
|
261
|
+
...(repairPlan ? { repairPlan } : {}),
|
|
262
|
+
output: [
|
|
263
|
+
"SotuRail brain stale",
|
|
264
|
+
`checked_records: ${freshness.checkedRecords}`,
|
|
265
|
+
`new_events: ${newEvents.length}`,
|
|
266
|
+
`suspect: ${freshness.suspect}`,
|
|
267
|
+
`stale: ${freshness.stale}`,
|
|
268
|
+
`relocated: ${freshness.events.filter((event) => event.status === "relocated").length}`,
|
|
269
|
+
`warnings: ${freshness.warnings.length}`,
|
|
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
|
+
] : []),
|
|
275
|
+
"",
|
|
276
|
+
...(newEvents.length > 0 ? newEvents.map((event) => `- ${event.recordId}: ${event.reason} [${event.status}]`) : ["No stale evidence drift detected."])
|
|
277
|
+
].join("\n") + "\n"
|
|
278
|
+
};
|
|
279
|
+
}
|
|
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 = {}) {
|
|
323
|
+
await initBrain(root);
|
|
324
|
+
const paths = getWorkspacePaths(root);
|
|
325
|
+
const counts = await brainCounts(root);
|
|
326
|
+
const jsonlFiles = [
|
|
327
|
+
paths.brainClaimsFile,
|
|
328
|
+
paths.brainDecisionsFile,
|
|
329
|
+
paths.brainBugsFile,
|
|
330
|
+
paths.brainGapsFile,
|
|
331
|
+
paths.brainRulesFile,
|
|
332
|
+
paths.brainStaleEventsFile
|
|
333
|
+
];
|
|
334
|
+
const files = [];
|
|
335
|
+
for (const filePath of jsonlFiles) {
|
|
336
|
+
files.push({ path: relativeToRoot(root, filePath), present: await exists(filePath), validJsonl: await validJsonl(filePath) });
|
|
337
|
+
}
|
|
338
|
+
for (const filePath of [paths.brainProjectProfileFile, paths.brainIndexFile, paths.brainFreshnessFile]) {
|
|
339
|
+
files.push({ path: relativeToRoot(root, filePath), present: await exists(filePath) });
|
|
340
|
+
}
|
|
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
|
+
};
|
|
355
|
+
const findings = [
|
|
356
|
+
...(claims.some((claim) => !claim.sourcePath) ? ["Some claims are missing source paths."] : []),
|
|
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}`] : []),
|
|
359
|
+
...(counts.gaps > 0 ? [`Open gaps recorded: ${counts.gaps}`] : []),
|
|
360
|
+
...(counts.suspectOrStale > 0 ? [`Suspect or stale records/events: ${counts.suspectOrStale}`] : []),
|
|
361
|
+
...(rulesWithoutSources > 0 ? [`Rules without live brain sources: ${rulesWithoutSources}`] : []),
|
|
362
|
+
...(await exists(path.join(paths.brainExportsDir, "agent-brief.md")) ? [] : ["Agent brief export is missing."])
|
|
363
|
+
];
|
|
364
|
+
const repairPlan = options.repairPlan ? (await staleBrain(root, { repairPlan: true })).repairPlan : undefined;
|
|
365
|
+
const report = {
|
|
366
|
+
schemaVersion: "soturail.brain.doctor.v1",
|
|
367
|
+
ok: files.every((file) => file.present && file.validJsonl !== false),
|
|
368
|
+
createdAt: new Date().toISOString(),
|
|
369
|
+
files,
|
|
370
|
+
counts,
|
|
371
|
+
duplicateClaimGroups: duplicateGroups,
|
|
372
|
+
integrationStatus,
|
|
373
|
+
...(repairPlan ? { repairPlanPath: relativeToRoot(root, paths.brainRepairPlanMd) } : {}),
|
|
374
|
+
findings,
|
|
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
|
+
]
|
|
383
|
+
};
|
|
384
|
+
await writeJson(paths.brainDoctorFile, report);
|
|
385
|
+
return {
|
|
386
|
+
report,
|
|
387
|
+
output: [
|
|
388
|
+
"SotuRail brain doctor",
|
|
389
|
+
`ok: ${report.ok}`,
|
|
390
|
+
`claims: ${counts.claims}`,
|
|
391
|
+
`decisions: ${counts.decisions}`,
|
|
392
|
+
`bugs: ${counts.bugs}`,
|
|
393
|
+
`gaps: ${counts.gaps}`,
|
|
394
|
+
`rules: ${counts.rules}`,
|
|
395
|
+
`stale_events: ${counts.staleEvents}`,
|
|
396
|
+
`duplicate_claim_groups: ${duplicateGroups}`,
|
|
397
|
+
`rules_without_sources: ${rulesWithoutSources}`,
|
|
398
|
+
`doctor_json: ${relativeToRoot(root, paths.brainDoctorFile)}`,
|
|
399
|
+
...(repairPlan ? [`repair_plan: ${relativeToRoot(root, paths.brainRepairPlanMd)}`] : []),
|
|
400
|
+
"",
|
|
401
|
+
"Files:",
|
|
402
|
+
...files.map((file) => `- ${file.path}: ${file.present ? "present" : "missing"}${file.validJsonl === undefined ? "" : `, jsonl_valid=${file.validJsonl}`}`),
|
|
403
|
+
"",
|
|
404
|
+
"Integration status:",
|
|
405
|
+
...Object.entries(integrationStatus).map(([key, value]) => `- ${key}: ${value}`),
|
|
406
|
+
"",
|
|
407
|
+
"Findings:",
|
|
408
|
+
...(findings.length > 0 ? findings.map((finding) => `- ${finding}`) : ["- none"]),
|
|
409
|
+
"",
|
|
410
|
+
"Next commands:",
|
|
411
|
+
...report.nextCommands.map((command) => `- ${command}`)
|
|
412
|
+
].join("\n") + "\n"
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
export async function exportBrain(agent, root = process.cwd(), options = {}) {
|
|
416
|
+
await ensureProfile(root);
|
|
417
|
+
const paths = getWorkspacePaths(root);
|
|
418
|
+
const profile = JSON.parse(await fs.readFile(paths.brainProjectProfileFile, "utf8"));
|
|
419
|
+
const claims = await readJsonl(paths.brainClaimsFile);
|
|
420
|
+
const decisions = await readJsonl(paths.brainDecisionsFile);
|
|
421
|
+
const gaps = await readJsonl(paths.brainGapsFile);
|
|
422
|
+
const bugs = await readJsonl(paths.brainBugsFile);
|
|
423
|
+
const rules = await readJsonl(paths.brainRulesFile);
|
|
424
|
+
const freshness = await readJson(paths.brainFreshnessFile).catch(() => null);
|
|
425
|
+
const approvedMemory = await readJsonl(paths.memoryApprovedFile).catch(() => []);
|
|
426
|
+
const base = renderBrainBrief(agent, profile, claims, decisions, gaps, bugs, rules, freshness, approvedMemory, options);
|
|
427
|
+
const content = agent === "claude" ? `<project_brain>\n${base}\n</project_brain>\n` : base;
|
|
428
|
+
const outputPath = path.join(paths.brainExportsDir, `${agent}.md`);
|
|
429
|
+
await fs.writeFile(outputPath, content, "utf8");
|
|
430
|
+
if (agent === "generic")
|
|
431
|
+
await fs.writeFile(path.join(paths.brainExportsDir, "agent-brief.md"), content, "utf8");
|
|
432
|
+
return {
|
|
433
|
+
path: outputPath,
|
|
434
|
+
content,
|
|
435
|
+
output: [
|
|
436
|
+
"SotuRail brain export",
|
|
437
|
+
`agent: ${agent}`,
|
|
438
|
+
`path: ${relativeToRoot(root, outputPath)}`,
|
|
439
|
+
`limit: ${normalizeLimit(options.limit)}`,
|
|
440
|
+
`include_suspect: ${options.includeSuspect === true}`,
|
|
441
|
+
...(agent === "generic" ? [`generic_alias: ${relativeToRoot(root, path.join(paths.brainExportsDir, "agent-brief.md"))}`] : []),
|
|
442
|
+
"review_required: true"
|
|
443
|
+
].join("\n") + "\n"
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
export async function rulesFromBrain(root = process.cwd()) {
|
|
447
|
+
await ensureProfile(root);
|
|
448
|
+
const paths = getWorkspacePaths(root);
|
|
449
|
+
const claims = await readJsonl(paths.brainClaimsFile);
|
|
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));
|
|
453
|
+
const now = new Date().toISOString();
|
|
454
|
+
const rules = [];
|
|
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;
|
|
458
|
+
const ruleText = ruleFromClaim(claim.claim);
|
|
459
|
+
if (!ruleText)
|
|
460
|
+
continue;
|
|
461
|
+
const verified = claim.status === "verified";
|
|
462
|
+
rules.push({
|
|
463
|
+
schemaVersion: "soturail.brain.rule.v1",
|
|
464
|
+
id: makeRailId("rule", `${claim.id}:${ruleText}`),
|
|
465
|
+
rule: ruleText,
|
|
466
|
+
sourceClaimIds: [claim.id],
|
|
467
|
+
enforcement: ruleEnforcementForClaim(claim),
|
|
468
|
+
status: verified ? "active" : "draft",
|
|
469
|
+
createdAt: now
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
for (const decision of decisions.filter((item) => item.status === "active").slice(0, 10)) {
|
|
473
|
+
rules.push({
|
|
474
|
+
schemaVersion: "soturail.brain.rule.v1",
|
|
475
|
+
id: makeRailId("rule", `${decision.id}:${decision.decision}`),
|
|
476
|
+
rule: ruleFromDecision(decision),
|
|
477
|
+
sourceClaimIds: [],
|
|
478
|
+
sourceDecisionIds: [decision.id],
|
|
479
|
+
enforcement: "advisory",
|
|
480
|
+
status: "active",
|
|
481
|
+
createdAt: now
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
await appendRecordsIfNew(paths.brainRulesFile, rules);
|
|
485
|
+
await fs.mkdir(paths.rulesDir, { recursive: true });
|
|
486
|
+
const markdownPath = path.join(paths.rulesDir, "from-brain.md");
|
|
487
|
+
const jsonPath = path.join(paths.rulesDir, "from-brain.json");
|
|
488
|
+
await fs.writeFile(markdownPath, [
|
|
489
|
+
"# Brain-Derived Rules",
|
|
490
|
+
"",
|
|
491
|
+
"These rules are derived from verified Project Brain claims and active decisions. Suspect or stale claims are excluded from active rules by default.",
|
|
492
|
+
"",
|
|
493
|
+
...rules.map((rule) => `- [${rule.status}/${rule.enforcement}] ${rule.rule} (sources: ${[...rule.sourceClaimIds, ...(rule.sourceDecisionIds ?? [])].join(", ") || "none"})`),
|
|
494
|
+
""
|
|
495
|
+
].join("\n"), "utf8");
|
|
496
|
+
await writeJson(jsonPath, { schemaVersion: "soturail.rules.from-brain.v1", createdAt: now, rules });
|
|
497
|
+
return {
|
|
498
|
+
rules,
|
|
499
|
+
markdownPath,
|
|
500
|
+
jsonPath,
|
|
501
|
+
output: [
|
|
502
|
+
"SotuRail rules from-brain",
|
|
503
|
+
`rules_written: ${rules.length}`,
|
|
504
|
+
`jsonl: ${relativeToRoot(root, paths.brainRulesFile)}`,
|
|
505
|
+
`markdown: ${relativeToRoot(root, markdownPath)}`,
|
|
506
|
+
`json: ${relativeToRoot(root, jsonPath)}`,
|
|
507
|
+
"review_required: true"
|
|
508
|
+
].join("\n") + "\n"
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
export async function readBrainCounts(root = process.cwd()) {
|
|
512
|
+
return brainCounts(root);
|
|
513
|
+
}
|
|
514
|
+
export async function brainExportExists(root = process.cwd()) {
|
|
515
|
+
return exists(path.join(getWorkspacePaths(root).brainExportsDir, "agent-brief.md"));
|
|
516
|
+
}
|
|
517
|
+
async function scanClaims(root, now) {
|
|
518
|
+
const commit = await currentCommit(root);
|
|
519
|
+
const packageJson = await readPackageJson(root);
|
|
520
|
+
const claims = [];
|
|
521
|
+
const add = async (claim, kind, sourcePath, tags, validatedBy = []) => {
|
|
522
|
+
const evidence = await sourceEvidence(root, sourcePath, claim.split(/\s+/).slice(0, 4).join(" "));
|
|
523
|
+
claims.push({
|
|
524
|
+
schemaVersion: "soturail.brain.claim.v1",
|
|
525
|
+
id: makeRailId("claim", `${kind}:${claim}:${sourcePath}`),
|
|
526
|
+
claim,
|
|
527
|
+
kind,
|
|
528
|
+
status: evidence.exists ? "verified" : "unverified",
|
|
529
|
+
confidence: evidence.exists ? "high" : "medium",
|
|
530
|
+
sourcePath,
|
|
531
|
+
sourceCommit: commit,
|
|
532
|
+
sourceRange: evidence.sourceRange,
|
|
533
|
+
fileHash: evidence.fileHash,
|
|
534
|
+
rangeHash: evidence.rangeHash,
|
|
535
|
+
validatedBy,
|
|
536
|
+
relatedWorkflowIds: [],
|
|
537
|
+
relatedEvidenceIds: [],
|
|
538
|
+
tags,
|
|
539
|
+
createdAt: now,
|
|
540
|
+
updatedAt: now
|
|
541
|
+
});
|
|
542
|
+
};
|
|
543
|
+
if (packageJson.name)
|
|
544
|
+
await add(`Package name is ${packageJson.name}.`, "architecture", "package.json", ["package", "identity"]);
|
|
545
|
+
if (packageJson.version)
|
|
546
|
+
await add(`Package version is ${packageJson.version}.`, "release", "package.json", ["package", "version"], ["tests/soturail.test.ts"]);
|
|
547
|
+
await add(`CLI version source is ${SOTURAIL_VERSION}.`, "release", "src/core/version.ts", ["cli", "version"], ["tests/soturail.test.ts"]);
|
|
548
|
+
await add("Release notes live under docs/releases/.", "release", "src/core/release-preflight.ts", ["release", "docs"], ["tests/v070.test.ts"]);
|
|
549
|
+
await add("SotuRail keeps native acceleration optional.", "architecture", "package.json", ["native", "optional"]);
|
|
550
|
+
await add("No arbitrary shell execution is exposed through SotuRail MCP by default.", "security", "src/core/agent-runtime.ts", ["mcp", "security"], ["tests/v060.test.ts"]);
|
|
551
|
+
await add("Workflow Rail stores local workflow evidence under .soturail/workflows/.", "workflow", "src/core/workflow-store.ts", ["workflow", "evidence"], ["tests/v070.test.ts"]);
|
|
552
|
+
await add("Diagram Rail uses lightweight Mermaid validation and is not a full Mermaid parser.", "docs", "src/core/diagram-validator.ts", ["diagram", "validation"], ["tests/v070.test.ts"]);
|
|
553
|
+
await add("Evaluation reports are written under .soturail/eval/.", "test", "src/core/evaluation-suite.ts", ["eval", "reports"], ["tests/v061.test.ts"]);
|
|
554
|
+
for (const command of await detectCliCommands(root)) {
|
|
555
|
+
await add(`CLI command '${command}' is registered.`, "command", "src/cli.ts", ["command", command]);
|
|
556
|
+
}
|
|
557
|
+
for (const capability of listAgentCapabilities()) {
|
|
558
|
+
await add(`Agent host '${capability.id}' supports ${capability.promptOnlyFallback} prompt-only fallback.`, "agent", "src/core/agent-runtime.ts", ["agent", capability.id]);
|
|
559
|
+
}
|
|
560
|
+
return claims;
|
|
561
|
+
}
|
|
562
|
+
async function writeBrainIndex(root) {
|
|
563
|
+
const paths = getWorkspacePaths(root);
|
|
564
|
+
const claims = await readJsonl(paths.brainClaimsFile);
|
|
565
|
+
const decisions = await readJsonl(paths.brainDecisionsFile);
|
|
566
|
+
const gaps = await readJsonl(paths.brainGapsFile);
|
|
567
|
+
const rules = await readJsonl(paths.brainRulesFile);
|
|
568
|
+
const index = {
|
|
569
|
+
schemaVersion: "soturail.brain.index.v1",
|
|
570
|
+
updatedAt: new Date().toISOString(),
|
|
571
|
+
claims: claims.map((claim) => ({ id: claim.id, kind: claim.kind, status: claim.status, sourcePath: claim.sourcePath, tags: claim.tags })),
|
|
572
|
+
decisions: decisions.map((decision) => ({ id: decision.id, status: decision.status, sourcePath: decision.sourcePath, tags: decision.tags })),
|
|
573
|
+
gaps: gaps.map((gap) => ({ id: gap.id, severity: gap.severity, status: gap.status, sourcePath: gap.sourcePath })),
|
|
574
|
+
rules: rules.map((rule) => ({ id: rule.id, status: rule.status, sourceClaimIds: rule.sourceClaimIds }))
|
|
575
|
+
};
|
|
576
|
+
await writeJson(paths.brainIndexFile, index);
|
|
577
|
+
}
|
|
578
|
+
async function brainCounts(root) {
|
|
579
|
+
const paths = getWorkspacePaths(root);
|
|
580
|
+
const claims = await readJsonl(paths.brainClaimsFile);
|
|
581
|
+
const gaps = await readJsonl(paths.brainGapsFile);
|
|
582
|
+
const staleEvents = await readJsonl(paths.brainStaleEventsFile);
|
|
583
|
+
return {
|
|
584
|
+
claims: claims.length,
|
|
585
|
+
decisions: (await readJsonl(paths.brainDecisionsFile)).length,
|
|
586
|
+
bugs: (await readJsonl(paths.brainBugsFile)).length,
|
|
587
|
+
gaps: gaps.filter((gap) => gap.status === "open").length,
|
|
588
|
+
rules: (await readJsonl(paths.brainRulesFile)).length,
|
|
589
|
+
staleEvents: staleEvents.length,
|
|
590
|
+
suspectOrStale: claims.filter((claim) => claim.status === "suspect" || claim.status === "stale").length + staleEvents.filter((event) => event.status === "suspect" || event.status === "stale").length
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function defaultProfile(root, counts, now) {
|
|
594
|
+
return {
|
|
595
|
+
schemaVersion: "soturail.brain.project-profile.v1",
|
|
596
|
+
projectName: path.basename(root),
|
|
597
|
+
packageVersion: "unknown",
|
|
598
|
+
cliVersion: SOTURAIL_VERSION,
|
|
599
|
+
identity: "Local-first Context OS for AI coding agents.",
|
|
600
|
+
mainLanguageRuntime: ["TypeScript", "Node >=20"],
|
|
601
|
+
docsFolders: [],
|
|
602
|
+
releaseNotesPath: "unknown",
|
|
603
|
+
rails: [],
|
|
604
|
+
commands: [],
|
|
605
|
+
tests: [],
|
|
606
|
+
workflowHarnessDiagramEval: {},
|
|
607
|
+
agentHosts: [],
|
|
608
|
+
knownReleaseProcess: [],
|
|
609
|
+
counts,
|
|
610
|
+
updatedAt: now
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function defaultArchitecture(root, now) {
|
|
614
|
+
return {
|
|
615
|
+
schemaVersion: "soturail.brain.architecture.v1",
|
|
616
|
+
storage: {
|
|
617
|
+
jsonl: [
|
|
618
|
+
".soturail/brain/claims.jsonl",
|
|
619
|
+
".soturail/brain/decisions.jsonl",
|
|
620
|
+
".soturail/brain/bugs.jsonl",
|
|
621
|
+
".soturail/brain/gaps.jsonl",
|
|
622
|
+
".soturail/brain/rules.jsonl",
|
|
623
|
+
".soturail/brain/stale-events.jsonl"
|
|
624
|
+
],
|
|
625
|
+
jsonViews: [
|
|
626
|
+
".soturail/brain/project-profile.json",
|
|
627
|
+
".soturail/brain/architecture.json",
|
|
628
|
+
".soturail/brain/brain-index.json",
|
|
629
|
+
".soturail/brain/freshness.json",
|
|
630
|
+
".soturail/brain/doctor.json",
|
|
631
|
+
".soturail/brain/consolidation-report.json",
|
|
632
|
+
".soturail/brain/stale-repair-plan.json"
|
|
633
|
+
],
|
|
634
|
+
exports: [relativeToRoot(root, path.join(getWorkspacePaths(root).brainExportsDir, "agent-brief.md"))]
|
|
635
|
+
},
|
|
636
|
+
rails: [
|
|
637
|
+
{ name: "Memory", evidence: ".soturail/memory/" },
|
|
638
|
+
{ name: "Workflow", evidence: ".soturail/workflows/" },
|
|
639
|
+
{ name: "Harness", evidence: ".soturail/harness/" },
|
|
640
|
+
{ name: "Diagram", evidence: ".soturail/diagrams/ and docs/diagrams/" },
|
|
641
|
+
{ name: "Evaluation", evidence: ".soturail/eval/" }
|
|
642
|
+
],
|
|
643
|
+
boundaries: ["no external LLM calls", "no embeddings required", "no cloud service", "agent-safe exports are reviewed Markdown/tagged context"],
|
|
644
|
+
updatedAt: now
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
async function sourceEvidence(root, relativePath, needle, forcedRange) {
|
|
648
|
+
const absolute = path.resolve(root, relativePath);
|
|
649
|
+
const raw = await fs.readFile(absolute, "utf8").catch(() => "");
|
|
650
|
+
if (!raw)
|
|
651
|
+
return { exists: false, sourceRange: { startLine: 1, endLine: 1 }, fileHash: "sha256-missing", rangeHash: "sha256-missing" };
|
|
652
|
+
const lines = raw.split(/\r?\n/);
|
|
653
|
+
let startLine = forcedRange?.startLine ?? 1;
|
|
654
|
+
let endLine = forcedRange?.endLine ?? Math.min(lines.length, 20);
|
|
655
|
+
if (!forcedRange && needle) {
|
|
656
|
+
const index = lines.findIndex((line) => line.toLowerCase().includes(needle.toLowerCase().split(/\s+/)[0] ?? needle.toLowerCase()));
|
|
657
|
+
if (index >= 0) {
|
|
658
|
+
startLine = index + 1;
|
|
659
|
+
endLine = Math.min(lines.length, index + 3);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const rangeText = lines.slice(Math.max(0, startLine - 1), endLine).join("\n");
|
|
663
|
+
return {
|
|
664
|
+
exists: true,
|
|
665
|
+
sourceRange: { startLine, endLine },
|
|
666
|
+
fileHash: `sha256-${sha256Text(raw)}`,
|
|
667
|
+
rangeHash: `sha256-${sha256Text(rangeText)}`
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function staleEvent(recordId, reason, previousHash, currentHash, status, metadata = {}) {
|
|
671
|
+
return {
|
|
672
|
+
schemaVersion: "soturail.brain.stale-event.v1",
|
|
673
|
+
id: makeRailId("stale", `${recordId}:${reason}:${previousHash}:${currentHash}:${metadata.candidateRange?.startLine ?? ""}`),
|
|
674
|
+
recordId,
|
|
675
|
+
reason,
|
|
676
|
+
previousHash,
|
|
677
|
+
currentHash,
|
|
678
|
+
status,
|
|
679
|
+
...metadata,
|
|
680
|
+
createdAt: new Date().toISOString()
|
|
681
|
+
};
|
|
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
|
+
}
|
|
916
|
+
async function allRecallRecords(root) {
|
|
917
|
+
const paths = getWorkspacePaths(root);
|
|
918
|
+
return [
|
|
919
|
+
...(await readJsonl(paths.brainClaimsFile)).map((record) => ({ ...record, type: "claim" })),
|
|
920
|
+
...(await readJsonl(paths.brainDecisionsFile)).map((record) => ({ ...record, type: "decision", confidence: "medium" })),
|
|
921
|
+
...(await readJsonl(paths.brainBugsFile)).map((record) => ({ ...record, type: "bug", confidence: "medium" })),
|
|
922
|
+
...(await readJsonl(paths.brainGapsFile)).map((record) => ({ ...record, type: "gap", confidence: "low", tags: ["gap"], sourcePath: record.sourcePath })),
|
|
923
|
+
...(await readJsonl(paths.brainRulesFile)).map((record) => ({ ...record, type: "rule", confidence: "medium", sourcePath: ".soturail/brain/rules.jsonl", tags: ["rule"] }))
|
|
924
|
+
];
|
|
925
|
+
}
|
|
926
|
+
function recallText(record) {
|
|
927
|
+
return record.claim ?? record.decision ?? record.bug ?? record.gap ?? record.rule ?? record.title ?? record.id;
|
|
928
|
+
}
|
|
929
|
+
function statusScore(status) {
|
|
930
|
+
if (status === "verified" || status === "active" || status === "recurring")
|
|
931
|
+
return 4;
|
|
932
|
+
if (status === "unverified" || status === "open")
|
|
933
|
+
return 2;
|
|
934
|
+
if (status === "suspect")
|
|
935
|
+
return 0;
|
|
936
|
+
if (status === "stale")
|
|
937
|
+
return -3;
|
|
938
|
+
return 1;
|
|
939
|
+
}
|
|
940
|
+
function confidenceScore(confidence) {
|
|
941
|
+
if (confidence === "high")
|
|
942
|
+
return 3;
|
|
943
|
+
if (confidence === "medium")
|
|
944
|
+
return 1;
|
|
945
|
+
return 0;
|
|
946
|
+
}
|
|
947
|
+
function renderBrainBrief(agent, profile, claims, decisions, gaps, bugs, rules, freshness, approvedMemory, options = {}) {
|
|
948
|
+
const capability = getAgentCapability(agent);
|
|
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 = [
|
|
965
|
+
`# SotuRail Project Brain Brief (${agent})`,
|
|
966
|
+
"",
|
|
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`."] : []),
|
|
970
|
+
"",
|
|
971
|
+
"## Project Identity",
|
|
972
|
+
"",
|
|
973
|
+
`- project: ${profile.projectName}`,
|
|
974
|
+
`- package_version: ${profile.packageVersion}`,
|
|
975
|
+
`- cli_version: ${profile.cliVersion}`,
|
|
976
|
+
`- identity: ${profile.identity}`,
|
|
977
|
+
`- recommended_payloads: ${capability.recommendedPayloads.join(" + ")}`,
|
|
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
|
+
"",
|
|
983
|
+
"## Verified Claims",
|
|
984
|
+
"",
|
|
985
|
+
...(verifiedClaims.length > 0 ? verifiedClaims.map((claim) => `- ${claim.claim} (${claim.id}, source: ${claim.sourcePath}:${claim.sourceRange.startLine})`) : ["- none yet"]),
|
|
986
|
+
"",
|
|
987
|
+
"## Active Rules",
|
|
988
|
+
"",
|
|
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"),
|
|
994
|
+
"",
|
|
995
|
+
"## Active Decisions",
|
|
996
|
+
"",
|
|
997
|
+
...nonEmpty(activeDecisions.map((decision) => `- ${decision.title}: ${decision.decision} (source: ${decision.sourcePath})`), "- none yet"),
|
|
998
|
+
"",
|
|
999
|
+
"## Known Gaps",
|
|
1000
|
+
"",
|
|
1001
|
+
...nonEmpty(openGaps.map((gap) => `- [${gap.severity}] ${gap.gap} (source: ${gap.sourcePath})`), "- none recorded"),
|
|
1002
|
+
"",
|
|
1003
|
+
"## Recurring Bugs / Harness Patterns",
|
|
1004
|
+
"",
|
|
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"),
|
|
1014
|
+
"",
|
|
1015
|
+
"## Safe Commands",
|
|
1016
|
+
"",
|
|
1017
|
+
"- soturail brain scan",
|
|
1018
|
+
"- soturail brain consolidate --dry-run",
|
|
1019
|
+
"- soturail brain stale --repair-plan",
|
|
1020
|
+
"- soturail brain doctor",
|
|
1021
|
+
"- soturail context budget --explain",
|
|
1022
|
+
"- soturail workflow verify",
|
|
1023
|
+
"- soturail eval run --suite brain",
|
|
1024
|
+
"- npm run release:check",
|
|
1025
|
+
"",
|
|
1026
|
+
"## Critical Commands",
|
|
1027
|
+
"",
|
|
1028
|
+
"- npm run typecheck",
|
|
1029
|
+
"- npm run build",
|
|
1030
|
+
"- npm test",
|
|
1031
|
+
"- npm run release:check",
|
|
1032
|
+
"",
|
|
1033
|
+
"## Release And Workflow Notes",
|
|
1034
|
+
"",
|
|
1035
|
+
`- release_notes_path: ${profile.releaseNotesPath}`,
|
|
1036
|
+
`- workflow: ${profile.workflowHarnessDiagramEval.workflow ?? "unknown"}`,
|
|
1037
|
+
`- harness: ${profile.workflowHarnessDiagramEval.harness ?? "unknown"}`,
|
|
1038
|
+
`- diagram: ${profile.workflowHarnessDiagramEval.diagram ?? "unknown"}`,
|
|
1039
|
+
`- eval: ${profile.workflowHarnessDiagramEval.eval ?? "unknown"}`,
|
|
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
|
+
"",
|
|
1049
|
+
"## Approved Memory Summary",
|
|
1050
|
+
"",
|
|
1051
|
+
...(approvedMemory.length > 0 ? approvedMemory.slice(0, Math.min(limit, 5)).map((memory) => `- ${memory.text} (${memory.source})`) : ["- no approved memory exported"]),
|
|
1052
|
+
"",
|
|
1053
|
+
"## Stale Or Suspect Warnings",
|
|
1054
|
+
"",
|
|
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"),
|
|
1067
|
+
"",
|
|
1068
|
+
"## Safety Notes",
|
|
1069
|
+
"",
|
|
1070
|
+
"- Keep secrets local and redacted.",
|
|
1071
|
+
"- Raw logs need explicit recovery and review.",
|
|
1072
|
+
"- Brain claims are only as fresh as their source hashes.",
|
|
1073
|
+
"- Generated exports should be reviewed before agent handoff.",
|
|
1074
|
+
""
|
|
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.";
|
|
1096
|
+
}
|
|
1097
|
+
function ruleFromClaim(claim) {
|
|
1098
|
+
if (/release notes live under docs\/releases/i.test(claim))
|
|
1099
|
+
return "Release notes must live under docs/releases/.";
|
|
1100
|
+
if (/release|publish|npm|version|tag/i.test(claim))
|
|
1101
|
+
return `Verify release evidence for: ${claim.replace(/\.$/, "")}.`;
|
|
1102
|
+
if (/arbitrary shell|mcp/i.test(claim))
|
|
1103
|
+
return "Do not expose arbitrary shell execution through MCP.";
|
|
1104
|
+
if (/native/i.test(claim))
|
|
1105
|
+
return "Keep native acceleration optional.";
|
|
1106
|
+
if (/diagram|Mermaid/i.test(claim))
|
|
1107
|
+
return "Do not treat diagram validation as a full Mermaid parser.";
|
|
1108
|
+
return `Preserve evidence for: ${claim.replace(/\.$/, "")}.`;
|
|
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
|
+
}
|
|
1119
|
+
function nonEmpty(values, fallback) {
|
|
1120
|
+
return values.length > 0 ? values : [fallback];
|
|
1121
|
+
}
|
|
1122
|
+
function ruleFromDecision(decision) {
|
|
1123
|
+
return `Follow active decision: ${decision.title}.`;
|
|
1124
|
+
}
|
|
1125
|
+
async function readPackageJson(root) {
|
|
1126
|
+
const raw = await fs.readFile(path.join(root, "package.json"), "utf8").catch(() => "{}");
|
|
1127
|
+
try {
|
|
1128
|
+
const parsed = JSON.parse(raw);
|
|
1129
|
+
return parsed;
|
|
1130
|
+
}
|
|
1131
|
+
catch {
|
|
1132
|
+
return {};
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
async function detectCliCommands(root) {
|
|
1136
|
+
const raw = await fs.readFile(path.join(root, "src", "cli.ts"), "utf8").catch(() => "");
|
|
1137
|
+
return [...raw.matchAll(/register([A-Z][A-Za-z]+)Command\(program\)/g)]
|
|
1138
|
+
.map((match) => (match[1] ?? "").replace(/[A-Z]/g, (letter, index) => `${index === 0 ? "" : "-"}${letter.toLowerCase()}`))
|
|
1139
|
+
.filter(Boolean)
|
|
1140
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1141
|
+
}
|
|
1142
|
+
async function detectRails(root) {
|
|
1143
|
+
const candidates = [
|
|
1144
|
+
["memory", "src/commands/memory.ts"],
|
|
1145
|
+
["context", "src/commands/context.ts"],
|
|
1146
|
+
["workflow", "src/commands/workflow.ts"],
|
|
1147
|
+
["harness", "src/commands/harness.ts"],
|
|
1148
|
+
["diagram", "src/commands/diagram.ts"],
|
|
1149
|
+
["evaluation", "src/commands/eval.ts"],
|
|
1150
|
+
["agents", "src/commands/agents.ts"],
|
|
1151
|
+
["rules", "src/commands/rules.ts"],
|
|
1152
|
+
["brain", "src/commands/brain.ts"],
|
|
1153
|
+
["reverse", "src/commands/reverse.ts"]
|
|
1154
|
+
];
|
|
1155
|
+
const present = [];
|
|
1156
|
+
for (const [name, file] of candidates) {
|
|
1157
|
+
if (await exists(path.join(root, file)))
|
|
1158
|
+
present.push(name);
|
|
1159
|
+
}
|
|
1160
|
+
return present;
|
|
1161
|
+
}
|
|
1162
|
+
async function listDocsFolders(root) {
|
|
1163
|
+
const docsRoot = path.join(root, "docs");
|
|
1164
|
+
const entries = await fs.readdir(docsRoot, { withFileTypes: true }).catch(() => []);
|
|
1165
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => path.join("docs", entry.name).replace(/\\/g, "/")).sort();
|
|
1166
|
+
}
|
|
1167
|
+
async function listRelativeFiles(dir, root, predicate) {
|
|
1168
|
+
const output = [];
|
|
1169
|
+
async function visit(current) {
|
|
1170
|
+
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
1171
|
+
for (const entry of entries) {
|
|
1172
|
+
const absolute = path.join(current, entry.name);
|
|
1173
|
+
if (entry.isDirectory()) {
|
|
1174
|
+
if (!["node_modules", ".git", "dist", ".soturail"].includes(entry.name))
|
|
1175
|
+
await visit(absolute);
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
if (entry.isFile() && predicate(absolute))
|
|
1179
|
+
output.push(relativeToRoot(root, absolute).replace(/\\/g, "/"));
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
await visit(dir);
|
|
1183
|
+
return output.sort((a, b) => a.localeCompare(b));
|
|
1184
|
+
}
|
|
1185
|
+
async function currentCommit(root) {
|
|
1186
|
+
try {
|
|
1187
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, timeout: 3000, windowsHide: true });
|
|
1188
|
+
return stdout.trim() || "unknown";
|
|
1189
|
+
}
|
|
1190
|
+
catch {
|
|
1191
|
+
return "unknown";
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
async function appendRecordsIfNew(filePath, records) {
|
|
1195
|
+
const existing = new Set((await readJsonl(filePath)).map((record) => record.id));
|
|
1196
|
+
let added = 0;
|
|
1197
|
+
for (const record of records) {
|
|
1198
|
+
if (existing.has(record.id))
|
|
1199
|
+
continue;
|
|
1200
|
+
await appendJsonl(filePath, record);
|
|
1201
|
+
existing.add(record.id);
|
|
1202
|
+
added += 1;
|
|
1203
|
+
}
|
|
1204
|
+
return added;
|
|
1205
|
+
}
|
|
1206
|
+
async function ensureProfile(root) {
|
|
1207
|
+
const paths = getWorkspacePaths(root);
|
|
1208
|
+
if (!(await exists(paths.brainProjectProfileFile)))
|
|
1209
|
+
await scanBrain(root);
|
|
1210
|
+
}
|
|
1211
|
+
async function readJson(filePath) {
|
|
1212
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
1213
|
+
}
|
|
1214
|
+
async function validJsonl(filePath) {
|
|
1215
|
+
try {
|
|
1216
|
+
await readJsonl(filePath);
|
|
1217
|
+
return true;
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
return false;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
async function writeFileIfMissing(filePath, content) {
|
|
1224
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1225
|
+
if (!(await exists(filePath)))
|
|
1226
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
1227
|
+
}
|
|
1228
|
+
async function writeJsonIfMissing(filePath, value) {
|
|
1229
|
+
if (!(await exists(filePath)))
|
|
1230
|
+
await writeJson(filePath, value);
|
|
1231
|
+
}
|
|
1232
|
+
async function exists(filePath) {
|
|
1233
|
+
return fs.access(filePath).then(() => true).catch(() => false);
|
|
1234
|
+
}
|
|
1235
|
+
//# sourceMappingURL=project-brain.js.map
|