@vibecompass/vibecompass 0.15.0 → 0.16.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.
- package/CHANGELOG.md +118 -0
- package/README.md +71 -0
- package/package.json +1 -1
- package/src/brief-keywords.js +203 -0
- package/src/brief-lifecycle.js +638 -0
- package/src/brief-render.js +341 -0
- package/src/brief-settings.js +160 -0
- package/src/brief.js +1557 -0
- package/src/cli.js +269 -3
- package/src/decision-lineage.js +1717 -0
- package/src/generators/agent-files/template.js +2 -1
- package/src/index.js +34 -0
- package/src/project-memory.js +30 -4
- package/src/read-model.js +92 -2
- package/src/scaffold.js +10 -2
- package/src/session.js +30 -0
package/src/brief.js
ADDED
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createKeywordIndex, queryTermOrigins, queryTerms } from './brief-keywords.js';
|
|
4
|
+
import { BRIEF_MAX_COMPACTION, renderBrief, renderBriefUnit, renderedFollowUpCount } from './brief-render.js';
|
|
5
|
+
import { compileBriefExclusions, readBriefSettingsForRoot, validateExcludePattern } from './brief-settings.js';
|
|
6
|
+
import { collectDeclaredSuccessors, parseDecisionEntries, scanDecisionReferences } from './decision-lineage.js';
|
|
7
|
+
import { sha256Text, stableStringify } from './hash.js';
|
|
8
|
+
import { resolveLaneMarkerContext, resolveLaneSelection, validateLaneId } from './lane-marker.js';
|
|
9
|
+
import { loadProjectReadModelWithDocuments } from './read-model.js';
|
|
10
|
+
import { parseSimpleYaml } from './simple-yaml.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Session brief engine (plan task A3; D-357, D-359). A read-only function from
|
|
14
|
+
* a memory root plus task inputs to a selection result: which memory units a
|
|
15
|
+
* session should read first, packed whole into an estimated token budget,
|
|
16
|
+
* with an honest status and a prioritized follow-up list. It never writes;
|
|
17
|
+
* rendering lives in `brief-render.js` and persistence (lane `brief.md`) in
|
|
18
|
+
* `brief-lifecycle.js`. Contract: `architecture/platform/project-memory/session-brief.md`.
|
|
19
|
+
*
|
|
20
|
+
* Paths the root's `project.yaml` `brief.exclude` names are never read
|
|
21
|
+
* (D-364): the settings are read first, invalid settings fail closed, and the
|
|
22
|
+
* scan drops excluded files before opening them.
|
|
23
|
+
*
|
|
24
|
+
* Relations stay evidence-typed: a doc *covers* a file it lists under
|
|
25
|
+
* Involved files and *cites* a decision it mentions (mechanical); a decision
|
|
26
|
+
* *supersedes* or *amends* another only where its own text declares it
|
|
27
|
+
* (`decision-lineage.md`). Nothing here certifies that a decision governs a
|
|
28
|
+
* doc, file, or task, or that one is currently valid.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const BRIEF_CONTRACT_VERSION = 1;
|
|
32
|
+
export const BRIEF_DEFAULT_BUDGET = 6000;
|
|
33
|
+
export const BRIEF_MIN_BUDGET = 800;
|
|
34
|
+
export const BRIEF_RESERVE_TOKENS = 600;
|
|
35
|
+
export const BRIEF_FOLLOW_UP_CAP = 12;
|
|
36
|
+
export const BRIEF_STATUSES = Object.freeze(['complete', 'partial', 'incomplete', 'no-match']);
|
|
37
|
+
export const BRIEF_OVERVIEW_PATH = 'architecture/overview/project-shape.md';
|
|
38
|
+
|
|
39
|
+
// Selection tuning (plan Track A: tunable without amending D-359).
|
|
40
|
+
const DOC_FIELDS = {
|
|
41
|
+
title: { weight: 3, b: 0.3 },
|
|
42
|
+
body: { weight: 1, b: 0.75 },
|
|
43
|
+
guidance: { weight: 1.5, b: 0.75 },
|
|
44
|
+
};
|
|
45
|
+
const MAX_DOC_CANDIDATES = 10;
|
|
46
|
+
const MAX_DECISION_CANDIDATES = 14;
|
|
47
|
+
// A doc whose mandatory companions would take more than a quarter of the
|
|
48
|
+
// unit capacity (and never less than a quarter of the default capacity)
|
|
49
|
+
// becomes a follow-up read, so one citation-heavy doc cannot crowd out the
|
|
50
|
+
// rest of the brief.
|
|
51
|
+
const MAX_COMPANION_SHARE = 0.25;
|
|
52
|
+
const MIN_COMPANION_ALLOWANCE = Math.round(MAX_COMPANION_SHARE * (BRIEF_DEFAULT_BUDGET - BRIEF_RESERVE_TOKENS));
|
|
53
|
+
const MAX_NOTE_CANDIDATES = 2;
|
|
54
|
+
const RELATIVE_SCORE_FLOOR = 0.35;
|
|
55
|
+
const MIN_KEYWORD_SCORE = 1.5;
|
|
56
|
+
const FILE_MATCH_WEIGHT = 12;
|
|
57
|
+
const LANE_DOC_WEIGHT = 14;
|
|
58
|
+
const FEATURE_WEIGHT = 4;
|
|
59
|
+
const CITATION_WEIGHT = 6;
|
|
60
|
+
// Evidence lists and file inventories cite decisions without saying anything about them.
|
|
61
|
+
const NON_PROPAGATING_SECTIONS = new Set(['Review metadata', 'Involved files']);
|
|
62
|
+
const DISTINCTIVE_TERM_LIMIT = 3;
|
|
63
|
+
const STRONG_TOPIC_TERMS = 3;
|
|
64
|
+
const NARROW_PACKED_UNITS = 4;
|
|
65
|
+
const DISTINCTIVE_MAX_DOCUMENT_SHARE = 0.02;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds a session brief selection result. Options: `rootDir` (required),
|
|
69
|
+
* `task`, `files` (`repo:path`), `featureSlugs`, `claims`, `laneId`, and
|
|
70
|
+
* `budget` (estimated tokens, default 6,000, minimum 800). `exclude` adds
|
|
71
|
+
* patterns to the root's own `brief.exclude`, and `repoAliases` maps other
|
|
72
|
+
* repo ids (`{ 'vibecompass-app': 'app' }`) onto this root's; the dual-root
|
|
73
|
+
* adapter passes both (D-364). Invalid options throw; retrieval failures,
|
|
74
|
+
* invalid brief settings included, return status `incomplete` with a reason.
|
|
75
|
+
*/
|
|
76
|
+
export async function buildSessionBrief(options = {}) {
|
|
77
|
+
const input = normalizeBriefOptions(options);
|
|
78
|
+
const gaps = [];
|
|
79
|
+
const warnings = [];
|
|
80
|
+
let retrievalError = null;
|
|
81
|
+
|
|
82
|
+
let lane = null;
|
|
83
|
+
if (input.laneId) {
|
|
84
|
+
try {
|
|
85
|
+
lane = await readLane(input.rootDir, input.laneId);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
retrievalError = `lane "${input.laneId}" could not be read: ${errorMessage(error)}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const task = input.task || lane?.workingOn || '';
|
|
92
|
+
if (!task && !retrievalError) {
|
|
93
|
+
throw new Error('A brief needs task text: pass a task, or a lane with a recorded working-on summary.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// D-364: exclusions are known before any canonical document is read, and
|
|
97
|
+
// settings that cannot be trusted stop the read entirely.
|
|
98
|
+
let exclusions = null;
|
|
99
|
+
if (!retrievalError) {
|
|
100
|
+
const settings = await readBriefSettingsForRoot(input.rootDir);
|
|
101
|
+
if (settings.problems.length > 0) {
|
|
102
|
+
retrievalError = `project.yaml brief settings are invalid, so no canonical document was read (D-364): ${settings.problems.join('; ')}`;
|
|
103
|
+
} else {
|
|
104
|
+
exclusions = compileBriefExclusions(uniqueStrings([...settings.exclude, ...input.exclude]));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let loaded = null;
|
|
109
|
+
if (!retrievalError) {
|
|
110
|
+
try {
|
|
111
|
+
loaded = await loadProjectReadModelWithDocuments(input.rootDir, { exclude: (relativePath) => exclusions.matches(relativePath) });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
retrievalError = `the read model could not be built: ${errorMessage(error)}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const overviewExcluded = exclusions?.matches(BRIEF_OVERVIEW_PATH) ?? false;
|
|
118
|
+
const overviewExists = !overviewExcluded && (await pathExists(path.join(input.rootDir, BRIEF_OVERVIEW_PATH)));
|
|
119
|
+
if (!overviewExists) {
|
|
120
|
+
gaps.push({
|
|
121
|
+
code: 'missing-overview',
|
|
122
|
+
path: BRIEF_OVERVIEW_PATH,
|
|
123
|
+
message: overviewExcluded
|
|
124
|
+
? `The orientation overview \`${BRIEF_OVERVIEW_PATH}\` is excluded from briefs (project.yaml \`brief.exclude\`); whole-project orientation is not available from the brief.`
|
|
125
|
+
: `No orientation overview at \`${BRIEF_OVERVIEW_PATH}\`; whole-project orientation is not available from memory.`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (lane && !lane.handoffExists) {
|
|
129
|
+
gaps.push({
|
|
130
|
+
code: 'missing-handoff',
|
|
131
|
+
path: lane.handoffPath,
|
|
132
|
+
message: `Lane \`${lane.id}\` has no \`handoff.md\`; its baton-pass state is not recorded.`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const context = {
|
|
137
|
+
input: { ...input, task },
|
|
138
|
+
lane,
|
|
139
|
+
gaps,
|
|
140
|
+
warnings,
|
|
141
|
+
overview: { path: BRIEF_OVERVIEW_PATH, exists: overviewExists },
|
|
142
|
+
exclude: exclusions?.patterns ?? null,
|
|
143
|
+
corpusDigest: loaded ? computeBriefCorpusDigest(loaded.documents, exclusions.patterns) : null,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (retrievalError) {
|
|
147
|
+
return finalizeRetrievalFailure(context, retrievalError);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const selection = selectCandidates(context, loaded, task);
|
|
151
|
+
return packBrief(context, selection);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Sets `budget.estimated_tokens` to the size of the rendered brief, which
|
|
156
|
+
* itself prints that number: iterate to the fixed point.
|
|
157
|
+
*/
|
|
158
|
+
function measureBrief(result) {
|
|
159
|
+
result.render.follow_ups_shown = renderedFollowUpCount(result);
|
|
160
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
161
|
+
const estimate = estimateBriefTokens(renderBrief(result));
|
|
162
|
+
if (estimate === result.budget.estimated_tokens) return;
|
|
163
|
+
result.budget.estimated_tokens = estimate;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Estimated tokens: Unicode code points ÷ 4, rounded up. */
|
|
168
|
+
export function estimateBriefTokens(text) {
|
|
169
|
+
let count = 0;
|
|
170
|
+
for (const _ of String(text ?? '')) count += 1;
|
|
171
|
+
return Math.ceil(count / 4);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Root and lane resolution for `vibecompass brief`, shared with the other
|
|
176
|
+
* lane-scoped commands and read-only: the root is `--root`, else the nearest
|
|
177
|
+
* worktree lane marker's root, else `cwd/.compass` (D-280); the lane is
|
|
178
|
+
* `--session`, else the marker's lane, else the single active lane, and two
|
|
179
|
+
* or more active lanes without a selection fail closed (D-277, D-353).
|
|
180
|
+
*/
|
|
181
|
+
export async function resolveBriefRequest(options = {}) {
|
|
182
|
+
const markerContext = await resolveLaneMarkerContext({
|
|
183
|
+
cwd: options.cwd,
|
|
184
|
+
explicitRootDir: options.rootDir ?? null,
|
|
185
|
+
explicitSessionId: options.sessionId ?? null,
|
|
186
|
+
});
|
|
187
|
+
const selection = resolveLaneSelection({
|
|
188
|
+
explicitSessionId: options.sessionId ?? null,
|
|
189
|
+
marker: markerContext.marker,
|
|
190
|
+
laneIds: await listLaneDirs(path.join(markerContext.rootDir, 'sessions', 'active')),
|
|
191
|
+
rootDir: markerContext.rootDir,
|
|
192
|
+
purpose: 'brief',
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
rootDir: markerContext.rootDir,
|
|
196
|
+
laneId: selection.sessionId,
|
|
197
|
+
laneSource: selection.source,
|
|
198
|
+
warnings: [...markerContext.warnings, ...selection.warnings],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Inputs and retrieval
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
function normalizeBriefOptions(options) {
|
|
207
|
+
if (typeof options.rootDir !== 'string' || options.rootDir.trim() === '') {
|
|
208
|
+
throw new Error('A brief needs a project-memory root (rootDir).');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const budget = options.budget ?? BRIEF_DEFAULT_BUDGET;
|
|
212
|
+
if (!Number.isInteger(budget) || budget < BRIEF_MIN_BUDGET) {
|
|
213
|
+
throw new RangeError(`Brief budget must be a whole number of at least ${BRIEF_MIN_BUDGET} estimated tokens (got ${budget}).`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const exclude = uniqueStrings(options.exclude);
|
|
217
|
+
for (const pattern of exclude) {
|
|
218
|
+
const problem = validateExcludePattern(pattern);
|
|
219
|
+
if (problem) throw new RangeError(`Brief exclude pattern ${problem}.`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const repoAliases = {};
|
|
223
|
+
for (const [alias, repoId] of Object.entries(options.repoAliases ?? {})) {
|
|
224
|
+
if (typeof alias === 'string' && alias.trim() && typeof repoId === 'string' && repoId.trim()) {
|
|
225
|
+
repoAliases[alias.trim()] = repoId.trim();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
rootDir: path.resolve(options.rootDir),
|
|
231
|
+
task: typeof options.task === 'string' ? options.task.trim() : '',
|
|
232
|
+
files: uniqueStrings(options.files),
|
|
233
|
+
featureSlugs: uniqueStrings(options.featureSlugs).map((slug) => slug.toLowerCase()),
|
|
234
|
+
claims: uniqueStrings(options.claims),
|
|
235
|
+
laneId: options.laneId ? validateLaneId(String(options.laneId)) : null,
|
|
236
|
+
budget,
|
|
237
|
+
exclude,
|
|
238
|
+
repoAliases,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function readLane(rootDir, laneId) {
|
|
243
|
+
const activeRoot = path.join(rootDir, 'sessions', 'active');
|
|
244
|
+
const laneDir = path.join(activeRoot, laneId);
|
|
245
|
+
const sessionPath = path.join(laneDir, 'session.yaml');
|
|
246
|
+
const data = parseSimpleYaml(await readFile(sessionPath, 'utf8'), { sourceName: sessionPath });
|
|
247
|
+
if (!data || typeof data !== 'object') {
|
|
248
|
+
throw new Error(`${relativeTo(rootDir, sessionPath)} is empty.`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const handoffPath = path.join(laneDir, 'handoff.md');
|
|
252
|
+
const handoff = await readOptional(handoffPath);
|
|
253
|
+
const otherLanes = [];
|
|
254
|
+
for (const otherId of await listLaneDirs(activeRoot)) {
|
|
255
|
+
if (otherId === laneId) continue;
|
|
256
|
+
const otherPath = path.join(activeRoot, otherId, 'session.yaml');
|
|
257
|
+
try {
|
|
258
|
+
const other = parseSimpleYaml(await readFile(otherPath, 'utf8'), { sourceName: otherPath });
|
|
259
|
+
otherLanes.push({
|
|
260
|
+
id: otherId,
|
|
261
|
+
workingOn: optionalString(other?.working_on),
|
|
262
|
+
claims: stringArray(other?.claimed_paths),
|
|
263
|
+
repos: stringArray(other?.repos),
|
|
264
|
+
});
|
|
265
|
+
} catch {
|
|
266
|
+
otherLanes.push({ id: otherId, workingOn: null, claims: [], repos: [], unreadable: true });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const worktrees = data.worktrees && typeof data.worktrees === 'object' && !Array.isArray(data.worktrees) ? data.worktrees : {};
|
|
271
|
+
const missingWorktrees = [];
|
|
272
|
+
for (const [repoId, worktreePath] of Object.entries(worktrees).sort(([left], [right]) => left.localeCompare(right))) {
|
|
273
|
+
if (typeof worktreePath === 'string' && !(await pathExists(worktreePath))) {
|
|
274
|
+
missingWorktrees.push({ repo: repoId, path: worktreePath });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const lane = {
|
|
279
|
+
id: laneId,
|
|
280
|
+
sessionPath: relativeTo(rootDir, sessionPath),
|
|
281
|
+
handoffPath: relativeTo(rootDir, handoffPath),
|
|
282
|
+
handoffExists: handoff !== null,
|
|
283
|
+
handoff,
|
|
284
|
+
workingOn: optionalString(data.working_on),
|
|
285
|
+
claims: stringArray(data.claimed_paths),
|
|
286
|
+
featureSlugs: stringArray(data.feature_slugs).map((slug) => slug.toLowerCase()),
|
|
287
|
+
architectureDocs: stringArray(data.architecture_docs),
|
|
288
|
+
repos: stringArray(data.repos),
|
|
289
|
+
decisionSnapshot: Number.isInteger(data.decision_snapshot?.highest_decision_id)
|
|
290
|
+
? data.decision_snapshot.highest_decision_id
|
|
291
|
+
: null,
|
|
292
|
+
sessionDate: data.session_date == null ? null : optionalString(String(data.session_date)),
|
|
293
|
+
sessionNumber: Number.isInteger(data.session_number) ? data.session_number : null,
|
|
294
|
+
otherLanes,
|
|
295
|
+
missingWorktrees,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// D-364 bindings: exactly the lane scratch this brief read. The session
|
|
299
|
+
// hash covers only the fields above, so resume bookkeeping (resumed_at,
|
|
300
|
+
// resume_count) does not make a brief stale.
|
|
301
|
+
lane.bindings = {
|
|
302
|
+
session: briefShortHash({
|
|
303
|
+
id: lane.id,
|
|
304
|
+
working_on: lane.workingOn,
|
|
305
|
+
claimed_paths: lane.claims,
|
|
306
|
+
feature_slugs: lane.featureSlugs,
|
|
307
|
+
architecture_docs: lane.architectureDocs,
|
|
308
|
+
repos: lane.repos,
|
|
309
|
+
decision_snapshot: lane.decisionSnapshot,
|
|
310
|
+
missing_worktrees: lane.missingWorktrees,
|
|
311
|
+
}),
|
|
312
|
+
handoff: handoff === null ? null : briefShortHash(handoff),
|
|
313
|
+
other_lanes: briefShortHash(otherLanes),
|
|
314
|
+
};
|
|
315
|
+
return lane;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The lane identity and scratch bindings a brief for this lane would record
|
|
320
|
+
* now (D-364), read exactly as `buildSessionBrief` reads them.
|
|
321
|
+
*/
|
|
322
|
+
export async function readBriefLaneBindings(rootDir, laneId) {
|
|
323
|
+
const lane = await readLane(path.resolve(rootDir), validateLaneId(String(laneId)));
|
|
324
|
+
return {
|
|
325
|
+
laneId: lane.id,
|
|
326
|
+
sessionDate: lane.sessionDate,
|
|
327
|
+
sessionNumber: lane.sessionNumber,
|
|
328
|
+
bindings: lane.bindings,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Digest of the canonical corpus a brief read: every scanned document's path
|
|
334
|
+
* and content hash (project.yaml included) plus the exclusion patterns in
|
|
335
|
+
* effect. Any change can change selection, so the lane brief binds it.
|
|
336
|
+
*/
|
|
337
|
+
export function computeBriefCorpusDigest(documents, excludePatterns = []) {
|
|
338
|
+
return sha256Text(
|
|
339
|
+
stableStringify({
|
|
340
|
+
exclude: [...excludePatterns].sort(),
|
|
341
|
+
documents: [...documents]
|
|
342
|
+
.map((document) => [document.path, sha256Text(document.content ?? '')])
|
|
343
|
+
.sort(([left], [right]) => left.localeCompare(right)),
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** First 16 hex digits of a SHA-256: enough to detect a changed input. */
|
|
349
|
+
export function briefShortHash(value) {
|
|
350
|
+
const hash = typeof value === 'string' && value.startsWith('sha256:') ? value : sha256Text(typeof value === 'string' ? value : stableStringify(value));
|
|
351
|
+
return hash.slice('sha256:'.length, 'sha256:'.length + 16);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The canonical files an emitted brief drew text from, each with a short
|
|
356
|
+
* content hash: docs and notes by file, decisions by the entries shown
|
|
357
|
+
* (`decisions/x.md#D-001,D-002`, hashed over those entries only).
|
|
358
|
+
*/
|
|
359
|
+
export function describeBriefInputs(result) {
|
|
360
|
+
const inputs = new Map();
|
|
361
|
+
const decisionsByFile = new Map();
|
|
362
|
+
for (const unit of result.units ?? []) {
|
|
363
|
+
if ((unit.kind === 'doc' || unit.kind === 'note') && unit.path && unit.source_hash) {
|
|
364
|
+
inputs.set(unit.path, briefShortHash(unit.source_hash));
|
|
365
|
+
} else if (unit.kind === 'lineage') {
|
|
366
|
+
for (const member of unit.members) {
|
|
367
|
+
if (!member.path || !member.section_hash) continue;
|
|
368
|
+
if (!decisionsByFile.has(member.path)) decisionsByFile.set(member.path, new Map());
|
|
369
|
+
decisionsByFile.get(member.path).set(member.decision_id, member.section_hash);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
for (const [filePath, entries] of decisionsByFile) {
|
|
374
|
+
const ids = [...entries.keys()].sort((left, right) => left - right);
|
|
375
|
+
inputs.set(`${filePath}#${ids.map(formatId).join(',')}`, briefShortHash(ids.map((id) => [id, entries.get(id)])));
|
|
376
|
+
}
|
|
377
|
+
return [...inputs.entries()].map(([key, hash]) => ({ key, hash })).sort((left, right) => left.key.localeCompare(right.key));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Current short hashes for input keys from `describeBriefInputs`, computed
|
|
382
|
+
* from scanned documents the same way; a key whose file or entry is gone (or
|
|
383
|
+
* excluded) maps to null.
|
|
384
|
+
*/
|
|
385
|
+
export function hashBriefInputs(documents, keys) {
|
|
386
|
+
const contentByPath = new Map(documents.map((document) => [document.path, document.content]));
|
|
387
|
+
const hashes = new Map();
|
|
388
|
+
for (const key of keys) {
|
|
389
|
+
const hashIndex = key.indexOf('#');
|
|
390
|
+
const filePath = hashIndex === -1 ? key : key.slice(0, hashIndex);
|
|
391
|
+
const content = contentByPath.get(filePath);
|
|
392
|
+
if (typeof content !== 'string') {
|
|
393
|
+
hashes.set(key, null);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (hashIndex === -1) {
|
|
397
|
+
hashes.set(key, briefShortHash(sha256Text(content)));
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const ids = key
|
|
401
|
+
.slice(hashIndex + 1)
|
|
402
|
+
.split(',')
|
|
403
|
+
.map((value) => Number(value.replace(/^D-/, '')))
|
|
404
|
+
.filter(Number.isInteger)
|
|
405
|
+
.sort((left, right) => left - right);
|
|
406
|
+
const entries = new Map(parseDecisionEntries(content).map((entry) => [entry.decision_id, sha256Text(entry.text)]));
|
|
407
|
+
hashes.set(key, ids.every((id) => entries.has(id)) ? briefShortHash(ids.map((id) => [id, entries.get(id)])) : null);
|
|
408
|
+
}
|
|
409
|
+
return hashes;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Selection
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
function selectCandidates(context, loaded, task) {
|
|
417
|
+
const { readModel, documents } = loaded;
|
|
418
|
+
const { input, lane } = context;
|
|
419
|
+
const aliases = buildAliasMap(readModel, input.repoAliases);
|
|
420
|
+
const contentByPath = new Map(documents.map((document) => [document.path, document.content]));
|
|
421
|
+
const relations = readModel.decision_lineage.relations;
|
|
422
|
+
const decisionEntries = buildDecisionEntryMap(documents);
|
|
423
|
+
|
|
424
|
+
const docs = readModel.features
|
|
425
|
+
.flatMap((feature) => feature.components.map((component) => ({ feature, component })))
|
|
426
|
+
.filter(({ component }) => component.path !== BRIEF_OVERVIEW_PATH)
|
|
427
|
+
.map(({ feature, component }) => ({
|
|
428
|
+
kind: 'doc',
|
|
429
|
+
id: `doc:${component.path}`,
|
|
430
|
+
path: component.path,
|
|
431
|
+
domain: feature.domain,
|
|
432
|
+
feature: feature.feature,
|
|
433
|
+
featureSlug: feature.feature_slug,
|
|
434
|
+
featureKey: feature.feature_key,
|
|
435
|
+
component: component.component,
|
|
436
|
+
status: component.status,
|
|
437
|
+
// Section text is re-read fence-aware from the canonical file; the read
|
|
438
|
+
// model's own section map lets fenced examples overwrite real sections.
|
|
439
|
+
...docSectionFields(contentByPath.get(component.path), component),
|
|
440
|
+
involvedFiles: component.involved_files,
|
|
441
|
+
warnings: component.warnings,
|
|
442
|
+
sourceHash: contentByPath.has(component.path) ? sha256Text(contentByPath.get(component.path)) : null,
|
|
443
|
+
}));
|
|
444
|
+
|
|
445
|
+
const decisions = readModel.decisions
|
|
446
|
+
.filter((decision) => decisionEntries.has(decision.decision_id))
|
|
447
|
+
.map((decision) => ({
|
|
448
|
+
kind: 'decision',
|
|
449
|
+
id: `decision:${decision.decision_id}`,
|
|
450
|
+
decisionId: decision.decision_id,
|
|
451
|
+
title: decision.title,
|
|
452
|
+
path: decision.path,
|
|
453
|
+
domainFile: decision.domain_file,
|
|
454
|
+
entry: decisionEntries.get(decision.decision_id),
|
|
455
|
+
}));
|
|
456
|
+
|
|
457
|
+
const index = createKeywordIndex(DOC_FIELDS, [
|
|
458
|
+
...docs.map((doc) => ({
|
|
459
|
+
id: doc.id,
|
|
460
|
+
fields: {
|
|
461
|
+
title: `${doc.component} ${doc.feature} ${doc.domain} ${path.basename(doc.path, '.md')}`,
|
|
462
|
+
body: doc.description ?? '',
|
|
463
|
+
guidance: [doc.retrievalGuidance, doc.retrievalScope].filter(Boolean).join('\n'),
|
|
464
|
+
},
|
|
465
|
+
})),
|
|
466
|
+
...decisions.map((decision) => ({
|
|
467
|
+
id: decision.id,
|
|
468
|
+
fields: { title: decision.title, body: decision.entry.fields.Decision ?? '', guidance: '' },
|
|
469
|
+
})),
|
|
470
|
+
]);
|
|
471
|
+
|
|
472
|
+
// The project's own name carries no topic inside its own memory.
|
|
473
|
+
const projectTerms = new Set(queryTerms(`${readModel.project.name ?? ''} ${readModel.project.slug ?? ''}`));
|
|
474
|
+
// Input file names add topic words: `app:src/lib/entitlements.ts` → "entitlements", "lib".
|
|
475
|
+
const pathWords = [...input.files, ...input.claims, ...(lane?.claims ?? [])].map(pathTopicText).join(' ');
|
|
476
|
+
const terms = uniqueStrings([...queryTerms(task), ...queryTerms(pathWords)]).filter((term) => !projectTerms.has(term));
|
|
477
|
+
const keywordScores = new Map(index.score(terms).map((result) => [result.id, result]));
|
|
478
|
+
const distinctive = pickDistinctiveTerms(terms, index);
|
|
479
|
+
|
|
480
|
+
// Mechanical evidence from the inputs: files and claims through the
|
|
481
|
+
// file-owner index, lane-declared architecture docs, feature slugs.
|
|
482
|
+
const pathInputs = [
|
|
483
|
+
...input.files.map((value) => ({ value, source: 'file' })),
|
|
484
|
+
...uniqueStrings([...input.claims, ...(lane?.claims ?? [])])
|
|
485
|
+
.filter((value) => !input.files.includes(value))
|
|
486
|
+
.map((value) => ({ value, source: 'claim' })),
|
|
487
|
+
];
|
|
488
|
+
const ownerCounts = countOwners(docs, pathInputs, aliases);
|
|
489
|
+
const featureSlugs = uniqueStrings([...input.featureSlugs, ...(lane?.featureSlugs ?? [])]);
|
|
490
|
+
const laneDocs = new Set((lane?.architectureDocs ?? []).map((docPath) => docPath.replace(/^docs:/, '')));
|
|
491
|
+
|
|
492
|
+
for (const doc of docs) {
|
|
493
|
+
doc.reasons = [];
|
|
494
|
+
doc.relations = [];
|
|
495
|
+
let evidence = 0;
|
|
496
|
+
|
|
497
|
+
for (const pathInput of pathInputs) {
|
|
498
|
+
const match = matchPathInput(doc, pathInput.value, aliases);
|
|
499
|
+
if (!match) continue;
|
|
500
|
+
const owners = ownerCounts.get(pathInput.value) ?? 1;
|
|
501
|
+
evidence += (FILE_MATCH_WEIGHT * match.strength) / Math.sqrt(owners);
|
|
502
|
+
const label = pathInput.source === 'claim' ? 'lane claim' : 'file';
|
|
503
|
+
doc.reasons.push(`covers ${label} \`${pathInput.value}\` (Involved files: \`${match.involved}\`)`);
|
|
504
|
+
doc.relations.push({
|
|
505
|
+
relation: 'covers',
|
|
506
|
+
source_kind: 'architecture',
|
|
507
|
+
source_path: doc.path,
|
|
508
|
+
source_section: 'Involved files',
|
|
509
|
+
source_hash: doc.sourceHash,
|
|
510
|
+
evidence: 'explicit',
|
|
511
|
+
basis: match.strength === 1 ? 'involved-file' : 'involved-directory',
|
|
512
|
+
involved_file: match.involved,
|
|
513
|
+
input: pathInput.value,
|
|
514
|
+
input_kind: pathInput.source,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (laneDocs.has(doc.path)) {
|
|
519
|
+
evidence += LANE_DOC_WEIGHT;
|
|
520
|
+
doc.reasons.push('declared by the lane (`architecture_docs`)');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const matchedFeature = featureSlugs.find(
|
|
524
|
+
(slug) => slug === doc.featureSlug || slug === doc.featureKey || slug === `${slugify(doc.domain)}/${doc.featureSlug}`,
|
|
525
|
+
);
|
|
526
|
+
if (matchedFeature) {
|
|
527
|
+
evidence += FEATURE_WEIGHT;
|
|
528
|
+
doc.reasons.push(`feature \`${matchedFeature}\``);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const keyword = keywordScores.get(doc.id);
|
|
532
|
+
doc.matchedTerms = keyword?.matched ?? [];
|
|
533
|
+
doc.keywordScore = keyword?.score ?? 0;
|
|
534
|
+
doc.evidenceScore = evidence;
|
|
535
|
+
doc.score = evidence + doc.keywordScore;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const decisionById = new Map(decisions.map((decision) => [decision.decisionId, decision]));
|
|
539
|
+
const namedDecisionIds = new Set(
|
|
540
|
+
scanDecisionReferences(task)
|
|
541
|
+
.refs.map((ref) => ref.id)
|
|
542
|
+
.filter((id) => decisionById.has(id)),
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const topKeyword = Math.max(0, ...[...keywordScores.values()].map((result) => result.score));
|
|
546
|
+
// Absent distinctive words alone never abstain: unfamiliar modifiers
|
|
547
|
+
// ("clearer", "sporadic") are common in real tasks, and a false no-match
|
|
548
|
+
// hides memory that exists. Abstain on them only when no single unit
|
|
549
|
+
// shares at least STRONG_TOPIC_TERMS of the task's words.
|
|
550
|
+
const strongestOverlap = Math.max(0, ...[...keywordScores.values()].map((result) => result.matched.length));
|
|
551
|
+
const noMatch =
|
|
552
|
+
docs.every((doc) => doc.evidenceScore === 0) &&
|
|
553
|
+
namedDecisionIds.size === 0 &&
|
|
554
|
+
(terms.length === 0 ||
|
|
555
|
+
topKeyword < MIN_KEYWORD_SCORE ||
|
|
556
|
+
(distinctiveTermsMostlyAbsent(distinctive, index) && strongestOverlap < STRONG_TOPIC_TERMS));
|
|
557
|
+
|
|
558
|
+
// Task words with no keyword match in the searched fields are disclosed, never guessed at.
|
|
559
|
+
const absent = terms.filter((term) => index.documentFrequency(term) === 0);
|
|
560
|
+
if (absent.length > 0) {
|
|
561
|
+
const origins = queryTermOrigins(`${task} ${pathWords}`);
|
|
562
|
+
const words = absent.map((term) => origins.get(term) ?? term);
|
|
563
|
+
context.gaps.push({
|
|
564
|
+
code: 'unmatched-terms',
|
|
565
|
+
terms: absent,
|
|
566
|
+
// Reports the search, not absence from memory: the index covers titles,
|
|
567
|
+
// Description, Retrieval guidance, and Decision text only, and the
|
|
568
|
+
// units shown matched other task words.
|
|
569
|
+
message: `No keyword match for ${words.slice(0, 6).map((word) => `"${word}"`).join(', ')}${words.length > 6 ? ` (+${words.length - 6} more)` : ''} in the fields the brief searches (titles, Description, Retrieval guidance, Decision text); other text may mention them.`,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (noMatch) {
|
|
574
|
+
return { noMatch: true, terms, distinctive, docs: [], namedDecisions: [], rankedDecisions: [], notes: [], relations, decisionById, decisionEntries, readModel };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// When the task's distinctive words are mostly unknown and nothing but
|
|
578
|
+
// keywords selected the memory, confidence is low: the match is kept
|
|
579
|
+
// narrow. Candidates are chosen and ranked as usual; only the top
|
|
580
|
+
// NARROW_PACKED_UNITS ranked units are packed and the rest are listed as
|
|
581
|
+
// follow-up reads, so the top-scoring topic is never dropped for a raw
|
|
582
|
+
// overlap count and a weak match does not fill the budget.
|
|
583
|
+
const narrow =
|
|
584
|
+
distinctiveTermsMostlyAbsent(distinctive, index) &&
|
|
585
|
+
docs.every((doc) => doc.evidenceScore === 0) &&
|
|
586
|
+
namedDecisionIds.size === 0;
|
|
587
|
+
|
|
588
|
+
// Docs with mechanical evidence (they cover an input file or claim, the lane
|
|
589
|
+
// declares them, or they belong to a named feature) skip the relative floor.
|
|
590
|
+
const selectedDocs = pickTop(
|
|
591
|
+
docs.filter((doc) => doc.evidenceScore > 0 || doc.keywordScore >= MIN_KEYWORD_SCORE),
|
|
592
|
+
MAX_DOC_CANDIDATES,
|
|
593
|
+
(doc) => doc.evidenceScore > 0,
|
|
594
|
+
);
|
|
595
|
+
const topDocScore = Math.max(selectedDocs[0]?.score ?? 0, 1e-9);
|
|
596
|
+
const selectedByPath = new Map(selectedDocs.map((doc) => [doc.path, doc]));
|
|
597
|
+
|
|
598
|
+
// Mechanical propagation: each selected doc shares a boost among the
|
|
599
|
+
// decisions it cites, weighted by the doc's relative score and diluted by
|
|
600
|
+
// how many decisions it cites.
|
|
601
|
+
const citedTargets = new Map();
|
|
602
|
+
for (const relation of relations) {
|
|
603
|
+
if (relation.relation !== 'cites' || !relation.target_exists || !selectedByPath.has(relation.source_path)) continue;
|
|
604
|
+
if (NON_PROPAGATING_SECTIONS.has(String(relation.source_section ?? '').split(' > ')[0])) continue;
|
|
605
|
+
if (!citedTargets.has(relation.source_path)) citedTargets.set(relation.source_path, new Map());
|
|
606
|
+
const targets = citedTargets.get(relation.source_path);
|
|
607
|
+
if (!targets.has(relation.target_decision_id)) targets.set(relation.target_decision_id, relation);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
for (const decision of decisions) {
|
|
611
|
+
const keyword = keywordScores.get(decision.id);
|
|
612
|
+
decision.matchedTerms = keyword?.matched ?? [];
|
|
613
|
+
decision.keywordScore = keyword?.score ?? 0;
|
|
614
|
+
decision.citedBy = [];
|
|
615
|
+
decision.citationBoost = 0;
|
|
616
|
+
}
|
|
617
|
+
for (const [docPath, targets] of citedTargets) {
|
|
618
|
+
const doc = selectedByPath.get(docPath);
|
|
619
|
+
const share = (CITATION_WEIGHT * (doc.score / topDocScore)) / Math.sqrt(targets.size);
|
|
620
|
+
for (const [decisionId, relation] of targets) {
|
|
621
|
+
const decision = decisionById.get(decisionId);
|
|
622
|
+
if (!decision) continue;
|
|
623
|
+
decision.citationBoost += share;
|
|
624
|
+
decision.citedBy.push({ relation, docScore: doc.score });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
for (const decision of decisions) {
|
|
628
|
+
decision.score = decision.keywordScore + decision.citationBoost;
|
|
629
|
+
decision.citedBy.sort((left, right) => right.docScore - left.docScore || left.relation.source_path.localeCompare(right.relation.source_path));
|
|
630
|
+
decision.reasons = [];
|
|
631
|
+
if (namedDecisionIds.has(decision.decisionId)) decision.reasons.push('named in the task');
|
|
632
|
+
if (decision.matchedTerms.length > 0) decision.reasons.push(`keywords: ${decision.matchedTerms.join(', ')}`);
|
|
633
|
+
for (const citation of decision.citedBy.slice(0, 2)) {
|
|
634
|
+
decision.reasons.push(`cited by \`${citation.relation.source_path}\` › ${citation.relation.source_section ?? 'preamble'}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const namedDecisions = [...namedDecisionIds].map((id) => decisionById.get(id)).sort((left, right) => right.decisionId - left.decisionId);
|
|
639
|
+
const rankedDecisions = pickTop(
|
|
640
|
+
decisions.filter(
|
|
641
|
+
(decision) =>
|
|
642
|
+
!namedDecisionIds.has(decision.decisionId) &&
|
|
643
|
+
(decision.keywordScore >= MIN_KEYWORD_SCORE || decision.citationBoost > 0),
|
|
644
|
+
),
|
|
645
|
+
MAX_DECISION_CANDIDATES,
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
for (const doc of selectedDocs) {
|
|
649
|
+
if (doc.matchedTerms.length > 0) doc.reasons.push(`keywords: ${doc.matchedTerms.join(', ')}`);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
noMatch: false,
|
|
654
|
+
narrow,
|
|
655
|
+
terms,
|
|
656
|
+
distinctive,
|
|
657
|
+
docs: selectedDocs,
|
|
658
|
+
namedDecisions,
|
|
659
|
+
rankedDecisions,
|
|
660
|
+
notes: selectNotes(readModel, relations, [...namedDecisions, ...rankedDecisions], documents),
|
|
661
|
+
relations,
|
|
662
|
+
decisionById,
|
|
663
|
+
decisionEntries,
|
|
664
|
+
readModel,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* No-match signal: at least two of the task's most distinctive terms (the
|
|
670
|
+
* rarest in the index, up to three) occur nowhere in the indexed memory, and
|
|
671
|
+
* they are at least two thirds of that set.
|
|
672
|
+
*/
|
|
673
|
+
function distinctiveTermsMostlyAbsent(distinctive, index) {
|
|
674
|
+
const absent = distinctive.filter((term) => index.documentFrequency(term) === 0).length;
|
|
675
|
+
return absent >= 2 && absent * 3 >= distinctive.length * 2;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function pickDistinctiveTerms(terms, index) {
|
|
679
|
+
const limit = Math.min(DISTINCTIVE_TERM_LIMIT, Math.ceil(terms.length / 3));
|
|
680
|
+
return [...terms]
|
|
681
|
+
.filter((term) => index.documentFrequency(term) <= Math.max(1, index.count * DISTINCTIVE_MAX_DOCUMENT_SHARE))
|
|
682
|
+
.sort((left, right) => index.documentFrequency(left) - index.documentFrequency(right) || left.localeCompare(right))
|
|
683
|
+
.slice(0, limit);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function pickTop(candidates, limit, bypassFloor = () => false) {
|
|
687
|
+
const sorted = [...candidates].sort(compareByScore);
|
|
688
|
+
const top = sorted[0]?.score ?? 0;
|
|
689
|
+
return sorted.filter((candidate) => bypassFloor(candidate) || candidate.score >= top * RELATIVE_SCORE_FLOOR).slice(0, limit);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function compareByScore(left, right) {
|
|
693
|
+
return right.score - left.score || left.id.localeCompare(right.id);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function selectNotes(readModel, relations, decisions, documents) {
|
|
697
|
+
const rankById = new Map(decisions.map((decision, rank) => [decision.decisionId, rank]));
|
|
698
|
+
const made = new Map();
|
|
699
|
+
for (const relation of relations) {
|
|
700
|
+
if (relation.relation !== 'made' || !rankById.has(relation.target_decision_id)) continue;
|
|
701
|
+
const rank = rankById.get(relation.target_decision_id);
|
|
702
|
+
const existing = made.get(relation.source_path);
|
|
703
|
+
if (!existing || rank < existing.rank) {
|
|
704
|
+
made.set(relation.source_path, { rank, decisionIds: [...(existing?.decisionIds ?? []), relation.target_decision_id] });
|
|
705
|
+
} else {
|
|
706
|
+
existing.decisionIds.push(relation.target_decision_id);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const sessions = new Map(readModel.sessions.map((session) => [session.path, session]));
|
|
711
|
+
const contentByPath = new Map(documents.map((document) => [document.path, document.content]));
|
|
712
|
+
return [...made.entries()]
|
|
713
|
+
.filter(([notePath]) => sessions.has(notePath))
|
|
714
|
+
.sort(([leftPath, left], [rightPath, right]) => left.rank - right.rank || rightPath.localeCompare(leftPath))
|
|
715
|
+
.slice(0, MAX_NOTE_CANDIDATES)
|
|
716
|
+
.map(([notePath, info]) => {
|
|
717
|
+
const session = sessions.get(notePath);
|
|
718
|
+
const content = contentByPath.get(notePath) ?? '';
|
|
719
|
+
return {
|
|
720
|
+
kind: 'note',
|
|
721
|
+
id: `note:${notePath}`,
|
|
722
|
+
path: notePath,
|
|
723
|
+
title: session.title,
|
|
724
|
+
date: session.session_date,
|
|
725
|
+
number: session.session_number,
|
|
726
|
+
madeDecisionIds: [...new Set(info.decisionIds)].sort((left, right) => left - right),
|
|
727
|
+
nextSession: extractSection(content, 'Next session should start with'),
|
|
728
|
+
sourceHash: sha256Text(content),
|
|
729
|
+
};
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
// Units (serialized shape: what `--json` prints and the renderer reads)
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
function buildUnits(context, selection) {
|
|
738
|
+
const units = [];
|
|
739
|
+
const { lane, input } = context;
|
|
740
|
+
|
|
741
|
+
if (lane) units.push(buildLaneUnit(lane));
|
|
742
|
+
if (selection.noMatch || selection.retrievalError) return units;
|
|
743
|
+
|
|
744
|
+
// Mandatory: decisions the task names, with their declared successors.
|
|
745
|
+
for (const decision of selection.namedDecisions) {
|
|
746
|
+
units.push(buildLineageUnit(decision, 'mandatory', selection));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Ranked: docs and lineage units interleave by score relative to the top of
|
|
750
|
+
// their kind. A doc carries its mandatory companions — lineage units for the
|
|
751
|
+
// decisions/cross-cutting.md entries its emitted text cites — and is packed
|
|
752
|
+
// only together with them.
|
|
753
|
+
const topDoc = Math.max(selection.docs[0]?.score ?? 0, 1e-9);
|
|
754
|
+
const topDecision = Math.max(selection.rankedDecisions[0]?.score ?? 0, 1e-9);
|
|
755
|
+
const ranked = [
|
|
756
|
+
...selection.docs.map((doc) => ({ unit: withCompanions(buildDocUnit(doc), selection), relative: doc.score / topDoc })),
|
|
757
|
+
...selection.rankedDecisions.map((decision) => ({
|
|
758
|
+
unit: buildLineageUnit(decision, 'ranked', selection),
|
|
759
|
+
relative: decision.score / topDecision,
|
|
760
|
+
})),
|
|
761
|
+
].sort((left, right) => right.relative - left.relative || left.unit.id.localeCompare(right.unit.id));
|
|
762
|
+
for (const [index, entry] of ranked.entries()) {
|
|
763
|
+
const withheld = selection.narrow && index >= NARROW_PACKED_UNITS;
|
|
764
|
+
units.push({ ...entry.unit, rank: index + 1, ...(withheld ? { withheld: true } : {}) });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
for (const note of selection.notes) units.push(buildNoteUnit(note));
|
|
768
|
+
if (lane) units.push(...buildWatchOutUnits(lane, selection, input));
|
|
769
|
+
return units;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function withCompanions(docUnit, selection) {
|
|
773
|
+
const shown = scanDecisionReferences(renderBriefUnit(docUnit)).refs.map((ref) => ref.id);
|
|
774
|
+
const companions = [];
|
|
775
|
+
for (const id of [...new Set(shown)].sort((left, right) => right - left)) {
|
|
776
|
+
const decision = selection.decisionById.get(id);
|
|
777
|
+
if (!decision || decision.domainFile !== 'cross-cutting') continue;
|
|
778
|
+
const citation = selection.relations.find(
|
|
779
|
+
(relation) => relation.relation === 'cites' && relation.source_path === docUnit.path && relation.target_decision_id === id,
|
|
780
|
+
);
|
|
781
|
+
companions.push({
|
|
782
|
+
...buildLineageUnit(decision, 'mandatory', selection),
|
|
783
|
+
id: `lineage:${formatId(id)}`,
|
|
784
|
+
companion_of: docUnit.id,
|
|
785
|
+
reasons: [`cross-cutting; cited by \`${docUnit.path}\`${citation?.source_section ? ` › ${citation.source_section}` : ''}`],
|
|
786
|
+
cited_by: citation ? [citation] : [],
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return { ...docUnit, companions };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function buildLaneUnit(lane) {
|
|
793
|
+
const openReview = [
|
|
794
|
+
extractSubsection(lane.handoff, 'Reviewer → Builder', 'Findings summary'),
|
|
795
|
+
extractSubsection(lane.handoff, 'Reviewer → Builder', 'Recommended next step'),
|
|
796
|
+
].filter(Boolean);
|
|
797
|
+
return {
|
|
798
|
+
id: `lane:${lane.id}`,
|
|
799
|
+
kind: 'lane',
|
|
800
|
+
tier: 'mandatory',
|
|
801
|
+
reasons: ['the brief is for this lane'],
|
|
802
|
+
lane_id: lane.id,
|
|
803
|
+
working_on: lane.workingOn,
|
|
804
|
+
handoff_path: lane.handoffPath,
|
|
805
|
+
handoff_exists: lane.handoffExists,
|
|
806
|
+
handoff_next: extractSubsection(lane.handoff, 'Builder → Reviewer', "What's next"),
|
|
807
|
+
open_review: openReview.length > 0 ? openReview.join('\n') : null,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function buildDocUnit(doc) {
|
|
812
|
+
const missingSections = doc.warnings
|
|
813
|
+
.filter((warning) => warning.code === 'architecture-missing-section')
|
|
814
|
+
.map((warning) => warning.message.match(/"## ([^"]+)"/)?.[1])
|
|
815
|
+
.filter(Boolean);
|
|
816
|
+
return {
|
|
817
|
+
id: doc.id,
|
|
818
|
+
kind: 'doc',
|
|
819
|
+
tier: 'ranked',
|
|
820
|
+
reasons: doc.reasons,
|
|
821
|
+
path: doc.path,
|
|
822
|
+
domain: doc.domain,
|
|
823
|
+
feature: doc.feature,
|
|
824
|
+
component: doc.component,
|
|
825
|
+
status: doc.status,
|
|
826
|
+
description: doc.description,
|
|
827
|
+
retrieval_guidance: doc.retrievalGuidance,
|
|
828
|
+
retrieval_scope: doc.retrievalScope,
|
|
829
|
+
missing_sections: missingSections,
|
|
830
|
+
matched_terms: doc.matchedTerms,
|
|
831
|
+
score: round(doc.score),
|
|
832
|
+
source_hash: doc.sourceHash,
|
|
833
|
+
relations: doc.relations,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function buildLineageUnit(decision, tier, selection) {
|
|
838
|
+
const successors = collectDeclaredSuccessors(selection.relations, decision.decisionId);
|
|
839
|
+
const chain = [
|
|
840
|
+
{ role: 'root', decisionId: decision.decisionId, via: [], successorRelations: [] },
|
|
841
|
+
...successors.map((successor) => ({
|
|
842
|
+
role: 'successor',
|
|
843
|
+
decisionId: successor.decision_id,
|
|
844
|
+
via: successor.via,
|
|
845
|
+
successorRelations: successor.relations,
|
|
846
|
+
})),
|
|
847
|
+
];
|
|
848
|
+
const members = chain.map((member) => {
|
|
849
|
+
const entry = selection.decisionEntries.get(member.decisionId);
|
|
850
|
+
const record = selection.decisionById.get(member.decisionId);
|
|
851
|
+
// Show every declared relation from this successor to another member of
|
|
852
|
+
// the unit, not only the one along its `via` path.
|
|
853
|
+
const earlier = new Set(chain.map((entry) => entry.decisionId).filter((id) => id !== member.decisionId));
|
|
854
|
+
const declared =
|
|
855
|
+
member.role === 'successor'
|
|
856
|
+
? selection.relations.filter(
|
|
857
|
+
(relation) =>
|
|
858
|
+
relation.source_kind === 'decision' &&
|
|
859
|
+
relation.source_decision_id === member.decisionId &&
|
|
860
|
+
(relation.relation === 'supersedes' || relation.relation === 'amends') &&
|
|
861
|
+
earlier.has(relation.target_decision_id),
|
|
862
|
+
)
|
|
863
|
+
: [];
|
|
864
|
+
return {
|
|
865
|
+
role: member.role,
|
|
866
|
+
decision_id: member.decisionId,
|
|
867
|
+
title: record?.title ?? entry?.title ?? null,
|
|
868
|
+
path: entry?.path ?? record?.path ?? null,
|
|
869
|
+
date: entry?.timestamp ? entry.timestamp.slice(0, 10) : null,
|
|
870
|
+
decision: entry?.fields.Decision ?? null,
|
|
871
|
+
impact: entry?.fields['Impact on prior decisions'] ?? entry?.fields.Impact ?? null,
|
|
872
|
+
via: member.via,
|
|
873
|
+
successor_relations: member.successorRelations,
|
|
874
|
+
declared_relations: declared,
|
|
875
|
+
section_hash: entry?.sectionHash ?? null,
|
|
876
|
+
};
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
const memberIds = new Set(members.map((member) => member.decision_id));
|
|
880
|
+
// Uncertified wording aimed at any member stays a follow-up read, even when
|
|
881
|
+
// its source is itself a declared successor in this unit: the unit shows
|
|
882
|
+
// bounded Decision and Impact excerpts, not the field the wording is in.
|
|
883
|
+
const unknownIncoming = selection.relations.filter(
|
|
884
|
+
(relation) =>
|
|
885
|
+
relation.relation === 'unknown' &&
|
|
886
|
+
relation.source_kind === 'decision' &&
|
|
887
|
+
memberIds.has(relation.target_decision_id) &&
|
|
888
|
+
relation.source_decision_id !== relation.target_decision_id,
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
id: `lineage:${formatId(decision.decisionId)}`,
|
|
893
|
+
kind: 'lineage',
|
|
894
|
+
tier,
|
|
895
|
+
reasons: decision.reasons,
|
|
896
|
+
root_decision_id: decision.decisionId,
|
|
897
|
+
members,
|
|
898
|
+
unknown_incoming: unknownIncoming,
|
|
899
|
+
cited_by: (decision.citedBy ?? []).map((citation) => citation.relation),
|
|
900
|
+
matched_terms: decision.matchedTerms,
|
|
901
|
+
score: round(decision.score),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function buildNoteUnit(note) {
|
|
906
|
+
return {
|
|
907
|
+
id: note.id,
|
|
908
|
+
kind: 'note',
|
|
909
|
+
tier: 'optional',
|
|
910
|
+
reasons: [`made ${note.madeDecisionIds.map(formatId).join(', ')} (Decisions made)`],
|
|
911
|
+
path: note.path,
|
|
912
|
+
title: note.title,
|
|
913
|
+
date: note.date,
|
|
914
|
+
number: note.number,
|
|
915
|
+
made_decision_ids: note.madeDecisionIds,
|
|
916
|
+
next_session: note.nextSession,
|
|
917
|
+
source_hash: note.sourceHash,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function buildWatchOutUnits(lane, selection, input) {
|
|
922
|
+
const units = [];
|
|
923
|
+
const laneClaims = uniqueStrings([...input.claims, ...lane.claims]);
|
|
924
|
+
|
|
925
|
+
if (lane.otherLanes.length > 0) {
|
|
926
|
+
units.push({
|
|
927
|
+
id: 'watch-out:other-lanes',
|
|
928
|
+
kind: 'watch-out',
|
|
929
|
+
tier: 'optional',
|
|
930
|
+
reasons: ['other active lanes'],
|
|
931
|
+
category: 'other-lanes',
|
|
932
|
+
snapshot: null,
|
|
933
|
+
items: lane.otherLanes.map((other) => ({
|
|
934
|
+
lane_id: other.id,
|
|
935
|
+
working_on: other.workingOn,
|
|
936
|
+
claims: other.claims,
|
|
937
|
+
overlapping_claims: other.claims.filter((claim) => laneClaims.some((own) => claimsOverlap(own, claim))),
|
|
938
|
+
unreadable: Boolean(other.unreadable),
|
|
939
|
+
})),
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (lane.decisionSnapshot !== null) {
|
|
944
|
+
const newer = [...selection.decisionById.values()]
|
|
945
|
+
.filter((decision) => decision.decisionId > lane.decisionSnapshot)
|
|
946
|
+
.sort((left, right) => right.decisionId - left.decisionId)
|
|
947
|
+
.map((decision) => ({ decision_id: decision.decisionId, title: decision.title, path: decision.path }));
|
|
948
|
+
if (newer.length > 0) {
|
|
949
|
+
units.push({
|
|
950
|
+
id: 'watch-out:newer-decisions',
|
|
951
|
+
kind: 'watch-out',
|
|
952
|
+
tier: 'optional',
|
|
953
|
+
reasons: [`decisions appended after the lane snapshot ${formatId(lane.decisionSnapshot)}`],
|
|
954
|
+
category: 'newer-decisions',
|
|
955
|
+
snapshot: lane.decisionSnapshot,
|
|
956
|
+
items: newer,
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (lane.missingWorktrees.length > 0) {
|
|
962
|
+
units.push({
|
|
963
|
+
id: 'watch-out:stale-bindings',
|
|
964
|
+
kind: 'watch-out',
|
|
965
|
+
tier: 'optional',
|
|
966
|
+
reasons: ['recorded lane worktree paths are missing'],
|
|
967
|
+
category: 'stale-bindings',
|
|
968
|
+
snapshot: null,
|
|
969
|
+
items: lane.missingWorktrees,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return units;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// Packing, status, follow-ups
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
|
|
980
|
+
function packBrief(context, selection) {
|
|
981
|
+
const units = buildUnits(context, selection);
|
|
982
|
+
const budget = context.input.budget;
|
|
983
|
+
const fits = (candidate) => candidate.budget.estimated_tokens <= budget;
|
|
984
|
+
const mandatoryOmitted = (candidate) => candidate.omitted.filter((unit) => unit.tier === 'mandatory').length;
|
|
985
|
+
|
|
986
|
+
// Pack whole units in tier order against a unit capacity. The header,
|
|
987
|
+
// status, and follow-up list take about BRIEF_RESERVE_TOKENS; rather than
|
|
988
|
+
// guess, binary-search the capacity against the whole rendered brief,
|
|
989
|
+
// repacking from scratch each time so no emitted unit ever loses a
|
|
990
|
+
// successor it was rendered against. Skip-and-continue packing is not
|
|
991
|
+
// monotone in capacity, so the result always fits but is not guaranteed
|
|
992
|
+
// to be the largest fitting capacity. The frame is compacted (bounded
|
|
993
|
+
// header, status, and follow-up fields) only when the normal frame leaves
|
|
994
|
+
// no room for every mandatory unit; the most compact frame with no units
|
|
995
|
+
// fits the minimum budget for any accepted input.
|
|
996
|
+
let chosen = null;
|
|
997
|
+
for (let compaction = 0; compaction <= BRIEF_MAX_COMPACTION; compaction += 1) {
|
|
998
|
+
const candidate = packAtCompaction(context, selection, units, compaction, fits);
|
|
999
|
+
if (!candidate) continue;
|
|
1000
|
+
if (!chosen || mandatoryOmitted(candidate) < mandatoryOmitted(chosen)) chosen = candidate;
|
|
1001
|
+
if (mandatoryOmitted(chosen) === 0) break;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (!chosen || !fits(chosen)) {
|
|
1005
|
+
throw new Error(`Brief packing could not fit the ${budget}-token budget.`);
|
|
1006
|
+
}
|
|
1007
|
+
assertLineageSafety(chosen, selection.relations);
|
|
1008
|
+
return chosen;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function packAtCompaction(context, selection, units, compaction, fits) {
|
|
1012
|
+
const budget = context.input.budget;
|
|
1013
|
+
const full = assemble(context, selection, units, budget, compaction);
|
|
1014
|
+
if (fits(full)) return full;
|
|
1015
|
+
let best = null;
|
|
1016
|
+
let low = 0;
|
|
1017
|
+
let high = budget - 1;
|
|
1018
|
+
while (low <= high) {
|
|
1019
|
+
const middle = Math.floor((low + high) / 2);
|
|
1020
|
+
const candidate = assemble(context, selection, units, middle, compaction);
|
|
1021
|
+
if (fits(candidate)) {
|
|
1022
|
+
best = candidate;
|
|
1023
|
+
low = middle + 1;
|
|
1024
|
+
} else {
|
|
1025
|
+
high = middle - 1;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (best) return best;
|
|
1029
|
+
const empty = assemble(context, selection, units, 0, compaction);
|
|
1030
|
+
return fits(empty) ? empty : null;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function assemble(context, selection, units, capacity, compaction = 0) {
|
|
1034
|
+
const emitted = new Set();
|
|
1035
|
+
const included = [];
|
|
1036
|
+
const omitted = [];
|
|
1037
|
+
const covered = [];
|
|
1038
|
+
let used = 0;
|
|
1039
|
+
|
|
1040
|
+
for (const { companions = [], withheld = false, ...unit } of units) {
|
|
1041
|
+
if (unit.kind === 'lineage' && unit.members.every((member) => emitted.has(member.decision_id))) {
|
|
1042
|
+
covered.push(unit.id);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
if (withheld) {
|
|
1046
|
+
const tokens = estimateBriefTokens(renderBriefUnit(unit, { emittedDecisions: emitted }));
|
|
1047
|
+
omitted.push({ ...unit, estimated_tokens: tokens, omitted_reason: 'narrow-match' });
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// A doc and its mandatory companions pack atomically; companions already
|
|
1052
|
+
// shown by an earlier unit cost nothing.
|
|
1053
|
+
const group = [unit];
|
|
1054
|
+
const groupEmitted = new Set(emitted);
|
|
1055
|
+
let tokens = estimateBriefTokens(renderBriefUnit(unit, { emittedDecisions: groupEmitted }));
|
|
1056
|
+
if (unit.kind === 'lineage') for (const member of unit.members) groupEmitted.add(member.decision_id);
|
|
1057
|
+
const sizes = [tokens];
|
|
1058
|
+
for (const companion of companions) {
|
|
1059
|
+
if (companion.members.every((member) => groupEmitted.has(member.decision_id))) continue;
|
|
1060
|
+
const size = estimateBriefTokens(renderBriefUnit(companion, { emittedDecisions: groupEmitted }));
|
|
1061
|
+
for (const member of companion.members) groupEmitted.add(member.decision_id);
|
|
1062
|
+
group.push(companion);
|
|
1063
|
+
sizes.push(size);
|
|
1064
|
+
tokens += size;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const companionTokens = tokens - sizes[0];
|
|
1068
|
+
const companionAllowance = Math.max(MIN_COMPANION_ALLOWANCE, MAX_COMPANION_SHARE * (context.input.budget - BRIEF_RESERVE_TOKENS));
|
|
1069
|
+
const companionsTooHeavy = companionTokens > companionAllowance;
|
|
1070
|
+
if (!companionsTooHeavy && used + tokens <= capacity) {
|
|
1071
|
+
used += tokens;
|
|
1072
|
+
group.forEach((member, index) => included.push({ ...member, estimated_tokens: sizes[index] }));
|
|
1073
|
+
for (const id of groupEmitted) emitted.add(id);
|
|
1074
|
+
} else {
|
|
1075
|
+
omitted.push({
|
|
1076
|
+
...unit,
|
|
1077
|
+
estimated_tokens: tokens,
|
|
1078
|
+
...(group.length > 1 ? { companion_ids: group.slice(1).map((member) => member.id) } : {}),
|
|
1079
|
+
omitted_reason: companionsTooHeavy ? 'companions-exceed-allowance' : 'budget',
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const status = decideStatus(selection, omitted);
|
|
1085
|
+
const result = baseResult(context, selection, {
|
|
1086
|
+
status,
|
|
1087
|
+
units: included,
|
|
1088
|
+
omitted,
|
|
1089
|
+
covered,
|
|
1090
|
+
followUps: buildFollowUps(selection, context, included, omitted),
|
|
1091
|
+
compaction,
|
|
1092
|
+
});
|
|
1093
|
+
measureBrief(result);
|
|
1094
|
+
return result;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function baseResult(context, selection, { status, units, omitted, covered, followUps, compaction = 0 }) {
|
|
1098
|
+
return {
|
|
1099
|
+
contract_version: BRIEF_CONTRACT_VERSION,
|
|
1100
|
+
status: status.status,
|
|
1101
|
+
status_reason: status.reason,
|
|
1102
|
+
// Set when memory could not be read (the brief is `incomplete` for that
|
|
1103
|
+
// reason); null for every brief built from memory, overflow included.
|
|
1104
|
+
retrieval_error: selection?.retrievalError ?? null,
|
|
1105
|
+
task: context.input.task,
|
|
1106
|
+
inputs: {
|
|
1107
|
+
files: context.input.files,
|
|
1108
|
+
feature_slugs: context.input.featureSlugs,
|
|
1109
|
+
claims: context.input.claims,
|
|
1110
|
+
lane_id: context.input.laneId,
|
|
1111
|
+
},
|
|
1112
|
+
source: {
|
|
1113
|
+
root_dir: context.input.rootDir,
|
|
1114
|
+
lane_id: context.lane?.id ?? null,
|
|
1115
|
+
manifest_hash: selection?.readModel?.manifest_state?.manifest_hash ?? null,
|
|
1116
|
+
// D-364 bindings for a persisted lane brief.
|
|
1117
|
+
corpus_digest: context.corpusDigest ?? null,
|
|
1118
|
+
exclude: context.exclude,
|
|
1119
|
+
lane_session: context.lane ? { date: context.lane.sessionDate, number: context.lane.sessionNumber } : null,
|
|
1120
|
+
lane_bindings: context.lane?.bindings ?? null,
|
|
1121
|
+
repo_aliases: context.input.repoAliases,
|
|
1122
|
+
},
|
|
1123
|
+
orientation: context.overview,
|
|
1124
|
+
budget: {
|
|
1125
|
+
limit: context.input.budget,
|
|
1126
|
+
reserve: BRIEF_RESERVE_TOKENS,
|
|
1127
|
+
method: 'unicode-code-points/4',
|
|
1128
|
+
estimated_tokens: 0,
|
|
1129
|
+
},
|
|
1130
|
+
selection: {
|
|
1131
|
+
query_terms: selection?.terms ?? [],
|
|
1132
|
+
distinctive_terms: selection?.distinctive ?? [],
|
|
1133
|
+
narrowed: Boolean(selection?.narrow),
|
|
1134
|
+
},
|
|
1135
|
+
units,
|
|
1136
|
+
omitted,
|
|
1137
|
+
covered_by_other_units: covered,
|
|
1138
|
+
follow_ups: followUps.map((entry, index) => ({ rank: index + 1, ...entry })),
|
|
1139
|
+
follow_up_cap: BRIEF_FOLLOW_UP_CAP,
|
|
1140
|
+
gaps: context.gaps,
|
|
1141
|
+
warnings: context.warnings,
|
|
1142
|
+
render: { compaction, follow_ups_shown: 0 },
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function decideStatus(selection, omitted) {
|
|
1147
|
+
if (selection.retrievalError) {
|
|
1148
|
+
return { status: 'incomplete', reason: `retrieval failed — ${selection.retrievalError}` };
|
|
1149
|
+
}
|
|
1150
|
+
if (selection.noMatch) {
|
|
1151
|
+
return omitted.some((unit) => unit.kind === 'lane')
|
|
1152
|
+
? { status: 'incomplete', reason: 'no memory matched the task, and the lane unit did not fit the budget' }
|
|
1153
|
+
: { status: 'no-match', reason: 'no architecture doc or decision matched the task' };
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const mandatoryOmitted = omitted.filter((unit) => unit.tier === 'mandatory');
|
|
1157
|
+
if (mandatoryOmitted.length > 0) {
|
|
1158
|
+
return {
|
|
1159
|
+
status: 'incomplete',
|
|
1160
|
+
reason: `${plural(mandatoryOmitted.length, 'mandatory unit')} did not fit the budget (${mandatoryOmitted.map((unit) => unit.id).join(', ')})`,
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
if (omitted.length > 0) {
|
|
1164
|
+
const withheld = omitted.filter((unit) => unit.omitted_reason === 'narrow-match').length;
|
|
1165
|
+
const parts = [];
|
|
1166
|
+
if (omitted.length > withheld) parts.push(`${plural(omitted.length - withheld, 'ranked or optional unit')} did not fit the budget`);
|
|
1167
|
+
if (withheld > 0) parts.push(`${plural(withheld, 'lower-confidence match', 'lower-confidence matches')} listed as reads only (narrow match)`);
|
|
1168
|
+
return { status: 'partial', reason: parts.join('; ') };
|
|
1169
|
+
}
|
|
1170
|
+
return { status: 'complete', reason: 'every selected unit fits the budget' };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function buildFollowUps(selection, context, included, omitted) {
|
|
1174
|
+
const entries = [];
|
|
1175
|
+
|
|
1176
|
+
// Required reads: omitted mandatory units — the lane handoff, then every
|
|
1177
|
+
// decision of the omitted lineage units, newest declared successor first.
|
|
1178
|
+
const requiredDecisions = [];
|
|
1179
|
+
for (const unit of omitted.filter((candidate) => candidate.tier === 'mandatory')) {
|
|
1180
|
+
if (unit.kind === 'lane') {
|
|
1181
|
+
entries.push({ priority: 'required', path: unit.handoff_path, heading: null, reason: `lane \`${unit.lane_id}\` handoff (the lane unit did not fit)` });
|
|
1182
|
+
} else {
|
|
1183
|
+
requiredDecisions.push(...lineageReads(unit, 'required'));
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
entries.push(...requiredDecisions.sort((left, right) => right.decision_id - left.decision_id));
|
|
1187
|
+
|
|
1188
|
+
// A failed retrieval can still point at the fixed entry points.
|
|
1189
|
+
if (selection.retrievalError) {
|
|
1190
|
+
if (context.overview.exists) {
|
|
1191
|
+
entries.push({ priority: 'required', path: BRIEF_OVERVIEW_PATH, heading: null, reason: 'whole-project orientation' });
|
|
1192
|
+
}
|
|
1193
|
+
entries.push({
|
|
1194
|
+
priority: 'required',
|
|
1195
|
+
path: 'decisions/INDEX.md',
|
|
1196
|
+
heading: null,
|
|
1197
|
+
reason: "decision index; read each entry's Impact on prior decisions before relying on it",
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
for (const unit of omitted.filter((candidate) => candidate.tier === 'ranked')) {
|
|
1202
|
+
const prefix = unit.omitted_reason === 'narrow-match' ? 'narrow match, read only: ' : '';
|
|
1203
|
+
if (unit.kind === 'doc') {
|
|
1204
|
+
entries.push({ priority: 'ranked', path: unit.path, heading: null, reason: shortReason(`${prefix}${unit.reasons[0] ?? 'ranked doc'}`) });
|
|
1205
|
+
} else {
|
|
1206
|
+
entries.push(...lineageReads(unit, 'ranked', prefix));
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Uncertified lineage wording aimed at an emitted decision: a read, never
|
|
1211
|
+
// a status or a relation. Grouped per source file and target decision.
|
|
1212
|
+
const unknownGroups = new Map();
|
|
1213
|
+
for (const unit of included.filter((candidate) => candidate.kind === 'lineage')) {
|
|
1214
|
+
for (const relation of unit.unknown_incoming) {
|
|
1215
|
+
const key = `${relation.source_path}\u0000${relation.target_decision_id}`;
|
|
1216
|
+
if (!unknownGroups.has(key)) {
|
|
1217
|
+
unknownGroups.set(key, { path: relation.source_path, target: relation.target_decision_id, sources: new Set(), cues: new Set() });
|
|
1218
|
+
}
|
|
1219
|
+
const group = unknownGroups.get(key);
|
|
1220
|
+
group.sources.add(relation.source_decision_id);
|
|
1221
|
+
group.cues.add(relation.cue);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
for (const group of [...unknownGroups.values()].sort((left, right) => right.target - left.target || left.path.localeCompare(right.path))) {
|
|
1225
|
+
const target = formatId(group.target);
|
|
1226
|
+
entries.push({
|
|
1227
|
+
priority: 'lineage',
|
|
1228
|
+
path: group.path,
|
|
1229
|
+
heading: [...group.sources].sort((left, right) => right - left).map(formatId).join(', '),
|
|
1230
|
+
reason: `uncertified lineage wording (${[...group.cues].sort().join(', ')}) about ${target}; judge it before relying on ${target}`,
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
for (const unit of omitted.filter((candidate) => candidate.tier === 'optional')) {
|
|
1235
|
+
if (unit.kind === 'note') {
|
|
1236
|
+
entries.push({ priority: 'optional', path: unit.path, heading: 'Next session should start with', reason: shortReason(unit.reasons[0]) });
|
|
1237
|
+
} else if (unit.kind === 'watch-out') {
|
|
1238
|
+
entries.push({ priority: 'optional', path: 'sessions/active/', heading: null, reason: `watch-out: ${unit.reasons[0]}` });
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const seen = new Set();
|
|
1243
|
+
return entries.filter((entry) => {
|
|
1244
|
+
const key = `${entry.path}\u0000${entry.heading ?? ''}`;
|
|
1245
|
+
if (seen.has(key)) return false;
|
|
1246
|
+
seen.add(key);
|
|
1247
|
+
return true;
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function lineageReads(unit, priority, prefix = '') {
|
|
1252
|
+
return [...unit.members]
|
|
1253
|
+
.sort((left, right) => right.decision_id - left.decision_id)
|
|
1254
|
+
.map((member) => ({
|
|
1255
|
+
priority,
|
|
1256
|
+
decision_id: member.decision_id,
|
|
1257
|
+
path: member.path,
|
|
1258
|
+
heading: formatId(member.decision_id),
|
|
1259
|
+
reason:
|
|
1260
|
+
member.role === 'root'
|
|
1261
|
+
? shortReason(`${prefix}${unit.reasons[0] ? `${unit.tier}: ${unit.reasons[0]}` : `${unit.tier} decision`}`)
|
|
1262
|
+
: `declared successor of ${formatId(unit.root_decision_id)} (${member.successor_relations.join(', ')})`,
|
|
1263
|
+
}));
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function finalizeRetrievalFailure(context, reason) {
|
|
1267
|
+
return packBrief(context, { retrievalError: reason, terms: [], distinctive: [], relations: [] });
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* D-359 guard, checked independently of how units were built: every emitted
|
|
1272
|
+
* decision appears with every declared superseding or amending successor,
|
|
1273
|
+
* transitively.
|
|
1274
|
+
*/
|
|
1275
|
+
function assertLineageSafety(result, relations) {
|
|
1276
|
+
const emitted = new Set();
|
|
1277
|
+
for (const unit of result.units) {
|
|
1278
|
+
if (unit.kind === 'lineage') for (const member of unit.members) emitted.add(member.decision_id);
|
|
1279
|
+
}
|
|
1280
|
+
for (const id of emitted) {
|
|
1281
|
+
for (const successor of collectDeclaredSuccessors(relations, id)) {
|
|
1282
|
+
if (!emitted.has(successor.decision_id)) {
|
|
1283
|
+
throw new Error(`Brief lineage guard: ${formatId(id)} would be emitted without its declared successor ${formatId(successor.decision_id)}.`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
// Helpers
|
|
1291
|
+
// ---------------------------------------------------------------------------
|
|
1292
|
+
|
|
1293
|
+
function buildDecisionEntryMap(documents) {
|
|
1294
|
+
const map = new Map();
|
|
1295
|
+
for (const document of documents) {
|
|
1296
|
+
if (document.kind !== 'decision') continue;
|
|
1297
|
+
for (const entry of parseDecisionEntries(document.content)) {
|
|
1298
|
+
const fields = {};
|
|
1299
|
+
for (const field of entry.fields) {
|
|
1300
|
+
if (!(field.name in fields)) fields[field.name] = collapse(field.text);
|
|
1301
|
+
}
|
|
1302
|
+
map.set(entry.decision_id, {
|
|
1303
|
+
decision_id: entry.decision_id,
|
|
1304
|
+
title: entry.title,
|
|
1305
|
+
path: document.path,
|
|
1306
|
+
timestamp: fields.Timestamp ?? null,
|
|
1307
|
+
fields,
|
|
1308
|
+
sectionHash: sha256Text(entry.text),
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return map;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function buildAliasMap(readModel, extraAliases = {}) {
|
|
1316
|
+
const aliases = new Map(readModel.repo_aliases);
|
|
1317
|
+
// Another root's repo ids (dual-root adapter, D-364): `vibecompass-app` → `app`.
|
|
1318
|
+
for (const [alias, repoId] of Object.entries(extraAliases)) {
|
|
1319
|
+
aliases.set(alias, repoId);
|
|
1320
|
+
aliases.set(alias.toLowerCase(), repoId);
|
|
1321
|
+
}
|
|
1322
|
+
for (const repo of readModel.project.repos ?? []) {
|
|
1323
|
+
if (typeof repo.path === 'string' && repo.path.trim()) {
|
|
1324
|
+
const normalized = repo.path.trim().replace(/^\.\//, '').replace(/\/+$/, '');
|
|
1325
|
+
aliases.set(normalized, repo.id);
|
|
1326
|
+
aliases.set(normalized.toLowerCase(), repo.id);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return aliases;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* `{ repo, paths }` for a `repo:path`, `<repo-dir>/path`, or bare path.
|
|
1334
|
+
* Involved files often use workspace paths (`vibecompass-app/src/x.ts`), and
|
|
1335
|
+
* the read model prefixes a single-repo doc's entries with its repo
|
|
1336
|
+
* (`app:vibecompass-app/src/x.ts`), so a leading repo-directory segment is
|
|
1337
|
+
* also tried stripped.
|
|
1338
|
+
*/
|
|
1339
|
+
function normalizeRepoPath(value, aliases) {
|
|
1340
|
+
const trimmed = String(value).trim().replace(/^\.\//, '');
|
|
1341
|
+
const prefixed = trimmed.match(/^([^:/\s]+):(.+)$/);
|
|
1342
|
+
let repo = null;
|
|
1343
|
+
let rest = trimmed;
|
|
1344
|
+
if (prefixed) {
|
|
1345
|
+
repo = aliases.get(prefixed[1]) ?? aliases.get(prefixed[1].toLowerCase()) ?? prefixed[1];
|
|
1346
|
+
rest = prefixed[2];
|
|
1347
|
+
}
|
|
1348
|
+
const cleaned = rest.replace(/^\.?\/+/, '').replace(/\/+$/, '');
|
|
1349
|
+
const paths = [cleaned];
|
|
1350
|
+
const slash = cleaned.indexOf('/');
|
|
1351
|
+
if (slash > 0) {
|
|
1352
|
+
const head = cleaned.slice(0, slash);
|
|
1353
|
+
const aliasRepo = aliases.get(head) ?? aliases.get(head.toLowerCase());
|
|
1354
|
+
if (aliasRepo && (!repo || repo === aliasRepo)) {
|
|
1355
|
+
repo = aliasRepo;
|
|
1356
|
+
paths.push(cleaned.slice(slash + 1));
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return { repo, paths: paths.filter(Boolean) };
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function matchPathInput(doc, value, aliases) {
|
|
1363
|
+
const wanted = normalizeRepoPath(value, aliases);
|
|
1364
|
+
if (wanted.paths.length === 0) return null;
|
|
1365
|
+
let best = null;
|
|
1366
|
+
for (const involved of doc.involvedFiles) {
|
|
1367
|
+
if (/\s/.test(involved)) continue;
|
|
1368
|
+
const owned = normalizeRepoPath(involved, aliases);
|
|
1369
|
+
if (wanted.repo && owned.repo && wanted.repo !== owned.repo) continue;
|
|
1370
|
+
let strength = 0;
|
|
1371
|
+
for (const ownedPath of owned.paths) {
|
|
1372
|
+
for (const wantedPath of wanted.paths) {
|
|
1373
|
+
if (ownedPath === wantedPath) strength = Math.max(strength, 1);
|
|
1374
|
+
else if (wantedPath.startsWith(`${ownedPath}/`) || ownedPath.startsWith(`${wantedPath}/`)) strength = Math.max(strength, 0.5);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (strength > (best?.strength ?? 0)) best = { strength, involved };
|
|
1378
|
+
}
|
|
1379
|
+
return best;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function countOwners(docs, pathInputs, aliases) {
|
|
1383
|
+
const counts = new Map();
|
|
1384
|
+
for (const { value } of pathInputs) {
|
|
1385
|
+
counts.set(value, docs.filter((doc) => matchPathInput(doc, value, aliases)).length || 1);
|
|
1386
|
+
}
|
|
1387
|
+
return counts;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** Basename (without extension) and parent directory of a `repo:path`. */
|
|
1391
|
+
function pathTopicText(value) {
|
|
1392
|
+
const segments = String(value).replace(/^[^:/\s]+:/, '').replace(/\/+$/, '').split('/').filter(Boolean);
|
|
1393
|
+
const base = (segments.at(-1) ?? '').replace(/\.[a-z0-9]+$/i, '');
|
|
1394
|
+
const parent = segments.at(-2) ?? '';
|
|
1395
|
+
return [STRUCTURAL_DIRECTORIES.has(parent.toLowerCase()) ? '' : parent, base].join(' ');
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const STRUCTURAL_DIRECTORIES = new Set(['src', 'lib', 'app', 'apps', 'packages', 'components', 'utils', 'tests', 'test', 'scripts', 'dist', 'public']);
|
|
1399
|
+
|
|
1400
|
+
function claimsOverlap(left, right) {
|
|
1401
|
+
const a = stripRepo(left);
|
|
1402
|
+
const b = stripRepo(right);
|
|
1403
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function stripRepo(value) {
|
|
1407
|
+
return String(value).replace(/^[^:/\s]+:/, '').replace(/\/+$/, '');
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function docSectionFields(content, component) {
|
|
1411
|
+
if (typeof content !== 'string') {
|
|
1412
|
+
return {
|
|
1413
|
+
description: component.description,
|
|
1414
|
+
retrievalGuidance: component.retrieval_guidance,
|
|
1415
|
+
retrievalScope: component.retrieval_scope,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, '');
|
|
1419
|
+
const sections = splitSections(body, 2);
|
|
1420
|
+
return {
|
|
1421
|
+
description: sections.get('Description') ?? null,
|
|
1422
|
+
retrievalGuidance: sections.get('Retrieval guidance') ?? null,
|
|
1423
|
+
retrievalScope: reviewMetadataField(sections.get('Review metadata'), 'Retrieval scope'),
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Level-2 sections of a markdown body, fence-aware: headings inside fenced
|
|
1429
|
+
* code are content, and the first section with a given title wins (docs such
|
|
1430
|
+
* as `file-schema.md` show example `## Description` blocks in fences).
|
|
1431
|
+
*/
|
|
1432
|
+
function splitSections(content, level = 2) {
|
|
1433
|
+
const sections = new Map();
|
|
1434
|
+
if (!content) return sections;
|
|
1435
|
+
const lines = content.split(/\r?\n/);
|
|
1436
|
+
const heading = new RegExp(`^#{1,${level}}\\s`);
|
|
1437
|
+
const exact = new RegExp(`^#{${level}}\\s+(.+?)\\s*$`);
|
|
1438
|
+
let fence = null;
|
|
1439
|
+
let current = null;
|
|
1440
|
+
for (const line of lines) {
|
|
1441
|
+
const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
1442
|
+
if (marker) {
|
|
1443
|
+
if (!fence) fence = marker[1][0];
|
|
1444
|
+
else if (marker[1][0] === fence) fence = null;
|
|
1445
|
+
}
|
|
1446
|
+
if (!fence && !marker && heading.test(line)) {
|
|
1447
|
+
const match = line.match(exact);
|
|
1448
|
+
current = match && !sections.has(match[1]) ? { title: match[1], lines: [] } : null;
|
|
1449
|
+
if (current) sections.set(current.title, current);
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
if (current) current.lines.push(line);
|
|
1453
|
+
}
|
|
1454
|
+
return new Map([...sections].map(([title, section]) => [title, section.lines.join('\n').trim() || null]));
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** Body of a level-2 section (`## Title`), trimmed; null when absent. */
|
|
1458
|
+
export function extractSection(content, title) {
|
|
1459
|
+
return splitSections(content, 2).get(title) ?? null;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** Body of `### sub` inside `## section`; null when absent. */
|
|
1463
|
+
function extractSubsection(content, section, sub) {
|
|
1464
|
+
return splitSections(extractSection(content, section), 3).get(sub) ?? null;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** One `- Label: value` bullet from a Review metadata section, continuation lines included. */
|
|
1468
|
+
function reviewMetadataField(section, label) {
|
|
1469
|
+
if (!section) return null;
|
|
1470
|
+
const lines = section.split(/\r?\n/);
|
|
1471
|
+
const prefix = new RegExp(`^\\s*[-*]\\s+${label}:\\s*`, 'i');
|
|
1472
|
+
const start = lines.findIndex((line) => prefix.test(line));
|
|
1473
|
+
if (start === -1) return null;
|
|
1474
|
+
const parts = [lines[start].replace(prefix, '')];
|
|
1475
|
+
for (const line of lines.slice(start + 1)) {
|
|
1476
|
+
if (!/^\s+\S/.test(line) || /^\s*[-*]\s+/.test(line)) break;
|
|
1477
|
+
parts.push(line.trim());
|
|
1478
|
+
}
|
|
1479
|
+
return parts.join(' ').trim() || null;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
async function listLaneDirs(activeRoot) {
|
|
1483
|
+
try {
|
|
1484
|
+
const entries = await readdir(activeRoot, { withFileTypes: true });
|
|
1485
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
1486
|
+
} catch {
|
|
1487
|
+
return [];
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
async function readOptional(filePath) {
|
|
1492
|
+
try {
|
|
1493
|
+
return await readFile(filePath, 'utf8');
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
if (error && error.code === 'ENOENT') return null;
|
|
1496
|
+
throw error;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
async function pathExists(filePath) {
|
|
1501
|
+
try {
|
|
1502
|
+
await stat(filePath);
|
|
1503
|
+
return true;
|
|
1504
|
+
} catch {
|
|
1505
|
+
return false;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function relativeTo(rootDir, filePath) {
|
|
1510
|
+
return path.relative(rootDir, filePath).split(path.sep).join('/');
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function uniqueStrings(values) {
|
|
1514
|
+
if (!Array.isArray(values)) return [];
|
|
1515
|
+
return [...new Set(values.filter((value) => typeof value === 'string').map((value) => value.trim()).filter(Boolean))];
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function stringArray(value) {
|
|
1519
|
+
return Array.isArray(value) ? uniqueStrings(value) : [];
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function optionalString(value) {
|
|
1523
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function slugify(value) {
|
|
1527
|
+
return String(value ?? '')
|
|
1528
|
+
.toLowerCase()
|
|
1529
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1530
|
+
.replace(/^-+|-+$/g, '');
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function collapse(value) {
|
|
1534
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function shortReason(value) {
|
|
1538
|
+
const text = collapse(value);
|
|
1539
|
+
const points = [...text];
|
|
1540
|
+
return points.length <= 110 ? text : `${points.slice(0, 109).join('')}…`;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function plural(count, noun, pluralNoun = `${noun}s`) {
|
|
1544
|
+
return `${count} ${count === 1 ? noun : pluralNoun}`;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function round(value) {
|
|
1548
|
+
return Math.round(value * 1000) / 1000;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function errorMessage(error) {
|
|
1552
|
+
return collapse(error instanceof Error ? error.message : String(error)).slice(0, 400);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
export function formatId(id) {
|
|
1556
|
+
return `D-${String(id).padStart(3, '0')}`;
|
|
1557
|
+
}
|