@getrift/rift 0.1.0-beta.12 → 0.1.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/src/cli/commands/onboard.d.ts +38 -0
  2. package/dist/src/cli/commands/onboard.d.ts.map +1 -1
  3. package/dist/src/cli/commands/onboard.js +176 -101
  4. package/dist/src/cli/commands/onboard.js.map +1 -1
  5. package/dist/src/cli/status/friend-header.d.ts +8 -1
  6. package/dist/src/cli/status/friend-header.d.ts.map +1 -1
  7. package/dist/src/cli/status/friend-header.js +93 -12
  8. package/dist/src/cli/status/friend-header.js.map +1 -1
  9. package/dist/src/cli/ui.d.ts +47 -0
  10. package/dist/src/cli/ui.d.ts.map +1 -0
  11. package/dist/src/cli/ui.js +166 -0
  12. package/dist/src/cli/ui.js.map +1 -0
  13. package/dist/src/jobs/handlers/dedupe-conversations.d.ts +25 -2
  14. package/dist/src/jobs/handlers/dedupe-conversations.d.ts.map +1 -1
  15. package/dist/src/jobs/handlers/dedupe-conversations.js +48 -9
  16. package/dist/src/jobs/handlers/dedupe-conversations.js.map +1 -1
  17. package/dist/src/jobs/handlers/ingest.d.ts.map +1 -1
  18. package/dist/src/jobs/handlers/ingest.js +8 -2
  19. package/dist/src/jobs/handlers/ingest.js.map +1 -1
  20. package/dist/src/mcp/server.d.ts.map +1 -1
  21. package/dist/src/mcp/server.js +43 -3
  22. package/dist/src/mcp/server.js.map +1 -1
  23. package/dist/src/mcp/tools/context-pack.js +163 -25
  24. package/dist/src/mcp/tools/context-pack.js.map +1 -1
  25. package/dist/src/observability/onboarding-metric.d.ts +115 -0
  26. package/dist/src/observability/onboarding-metric.d.ts.map +1 -0
  27. package/dist/src/observability/onboarding-metric.js +344 -0
  28. package/dist/src/observability/onboarding-metric.js.map +1 -0
  29. package/dist/src/retrieval/context-pack.d.ts +37 -0
  30. package/dist/src/retrieval/context-pack.d.ts.map +1 -1
  31. package/dist/src/retrieval/context-pack.js +165 -1
  32. package/dist/src/retrieval/context-pack.js.map +1 -1
  33. package/dist/src/retrieval/current-truth.d.ts +326 -0
  34. package/dist/src/retrieval/current-truth.d.ts.map +1 -0
  35. package/dist/src/retrieval/current-truth.js +747 -0
  36. package/dist/src/retrieval/current-truth.js.map +1 -0
  37. package/dist/src/retrieval/git-state.d.ts +53 -0
  38. package/dist/src/retrieval/git-state.d.ts.map +1 -0
  39. package/dist/src/retrieval/git-state.js +174 -0
  40. package/dist/src/retrieval/git-state.js.map +1 -0
  41. package/dist/src/server/routes/friend-status.d.ts +63 -0
  42. package/dist/src/server/routes/friend-status.d.ts.map +1 -1
  43. package/dist/src/server/routes/friend-status.js +97 -0
  44. package/dist/src/server/routes/friend-status.js.map +1 -1
  45. package/package.json +2 -1
@@ -0,0 +1,747 @@
1
+ /**
2
+ * Phrase triggers for `reasoning_archive`. Multi-word — these must be
3
+ * matched in word order against the lower-cased query. A reasoning
4
+ * question is almost always phrased with possessive ("my take") or
5
+ * historical-tense framing ("why did I", "how did I frame") — single-
6
+ * keyword matching would catch too many false positives.
7
+ */
8
+ const REASONING_PHRASE_TRIGGERS = [
9
+ "why did i",
10
+ "why did we",
11
+ "what did i decide",
12
+ "what did we decide",
13
+ "how did i frame",
14
+ "how did we frame",
15
+ "my take",
16
+ "my view",
17
+ "my philosophy",
18
+ "relationship between",
19
+ ];
20
+ /**
21
+ * Single-word triggers for `reasoning_archive`. These score the query as
22
+ * a taste/philosophy/origin question — old conversations are the right
23
+ * evidence, not trackers.
24
+ */
25
+ const REASONING_WORD_TRIGGERS = [
26
+ "favorite",
27
+ "favorites",
28
+ "favourite",
29
+ "favourites",
30
+ "taste",
31
+ "philosophy",
32
+ "origin",
33
+ "lineage",
34
+ ];
35
+ /**
36
+ * Phrase triggers for `current_truth`. Indicates the asker wants the
37
+ * present-tense state, not historical reasoning.
38
+ */
39
+ const CURRENT_PHRASE_TRIGGERS = [
40
+ "what's next",
41
+ "whats next",
42
+ "what is next",
43
+ "install path",
44
+ ];
45
+ /**
46
+ * Single-word triggers for `current_truth`. Strong signal that the
47
+ * answer should lead with trackers / source / recent state.
48
+ *
49
+ * `customers`/`clients`/`roster`/`freelancer` cover roster-shape
50
+ * questions (Q4: "who are Clément's customers as a freelancer") —
51
+ * roster questions are present-tense state, not historical reasoning,
52
+ * so they belong here even though they don't carry an explicit
53
+ * "current" / "today" marker.
54
+ */
55
+ const CURRENT_WORD_TRIGGERS = [
56
+ "current",
57
+ "latest",
58
+ "today",
59
+ "now",
60
+ "state",
61
+ "status",
62
+ "next",
63
+ "roadmap",
64
+ "launch",
65
+ "pending",
66
+ "shipped",
67
+ "version",
68
+ "customers",
69
+ "customer",
70
+ "clients",
71
+ "roster",
72
+ "freelancer",
73
+ "freelance",
74
+ ];
75
+ /**
76
+ * Classify a query's evidence intent. Deterministic and transparent so
77
+ * v0 can be debugged by eye. Algorithm:
78
+ *
79
+ * 1. Lower-case the query.
80
+ * 2. Check phrase triggers (multi-word) for each intent.
81
+ * 3. Check single-word triggers (matched as whole words via word
82
+ * boundaries — no partial matches like "currently" → "current").
83
+ * 4. If exactly one intent matches → that intent.
84
+ * 5. If both match → return `blended` (the asker is conflating
85
+ * "what's my taste on the current X").
86
+ * 6. If neither matches → `blended`.
87
+ *
88
+ * Note on Q7 ("difference between second brain and Rift"): only
89
+ * `relationship between` is in the reasoning-archive trigger list, not
90
+ * `difference between`. Q7 fires no trigger and falls to `blended`,
91
+ * which is the deliberate v0 outcome — disambiguation questions surface
92
+ * both the current product framing and the historical context without
93
+ * forcing an order.
94
+ */
95
+ export function classifyEvidenceIntent(query) {
96
+ const q = (query || "").toLowerCase();
97
+ if (!q.trim())
98
+ return "blended";
99
+ const reasoning = hasAnyPhrase(q, REASONING_PHRASE_TRIGGERS) ||
100
+ hasAnyWord(q, REASONING_WORD_TRIGGERS);
101
+ const current = hasAnyPhrase(q, CURRENT_PHRASE_TRIGGERS) ||
102
+ hasAnyWord(q, CURRENT_WORD_TRIGGERS);
103
+ if (current && !reasoning)
104
+ return "current_truth";
105
+ if (reasoning && !current)
106
+ return "reasoning_archive";
107
+ return "blended";
108
+ }
109
+ const CURRENT_STATE_CLAIM_WORDS = [
110
+ "current",
111
+ "latest",
112
+ "today",
113
+ "now",
114
+ "state",
115
+ "status",
116
+ "next",
117
+ "roadmap",
118
+ "launch",
119
+ "pending",
120
+ "shipped",
121
+ "version",
122
+ "install",
123
+ "onboarding",
124
+ "customer",
125
+ "customers",
126
+ "clients",
127
+ "roster",
128
+ ];
129
+ const FRAMING_CLAIM_WORDS = [
130
+ "pricing",
131
+ "price",
132
+ "brand",
133
+ "framing",
134
+ "history",
135
+ "positioning",
136
+ "narrative",
137
+ "messaging",
138
+ ];
139
+ /**
140
+ * Unambiguous rationale signals — always a reasoning claim. "decision" /
141
+ * "decide" are deliberately NOT here: they're ambiguous (see
142
+ * {@link DECISION_CLAIM_WORDS}).
143
+ */
144
+ const REASONING_CLAIM_WORDS = [
145
+ "why",
146
+ "taste",
147
+ "relationship",
148
+ "philosophy",
149
+ "favorite",
150
+ "favourite",
151
+ "origin",
152
+ "rationale",
153
+ ];
154
+ /**
155
+ * Rationale-shaped phrases — "why/how did we decide", "reasoning behind".
156
+ * These pin a decision query to reasoning regardless of qualifiers.
157
+ */
158
+ const REASONING_CLAIM_PHRASES = [
159
+ "why did",
160
+ "why we",
161
+ "how did we decide",
162
+ "how we decided",
163
+ "reasoning behind",
164
+ "rationale behind",
165
+ "thinking behind",
166
+ ];
167
+ /**
168
+ * Ambiguous decision words. "What's the latest decision about X" is a
169
+ * current-state question (what the decision *is*); "why did we decide X"
170
+ * is reasoning (caught by {@link REASONING_CLAIM_PHRASES}). So a bare
171
+ * decision word is reasoning ONLY when no current-state qualifier is
172
+ * present — otherwise the qualifier wins.
173
+ */
174
+ const DECISION_CLAIM_WORDS = ["decision", "decide", "decided"];
175
+ /**
176
+ * Classify the claim-type of a query. Precedence: rationale phrases →
177
+ * strong reasoning words → ambiguous decision-without-current-qualifier →
178
+ * current_state → framing → default current_state. A "why did we decide X"
179
+ * question is reasoning even if it mentions a current-state noun; "latest
180
+ * decision about X" is a current-state question (P2 fix). An ambiguous
181
+ * query defaults to the strictest tier discipline (current_state) so we
182
+ * never under-apply the trust gating.
183
+ */
184
+ export function classifyClaimType(query) {
185
+ const q = (query || "").toLowerCase();
186
+ if (!q.trim())
187
+ return "current_state";
188
+ if (hasAnyPhrase(q, REASONING_CLAIM_PHRASES))
189
+ return "reasoning";
190
+ if (hasAnyWord(q, REASONING_CLAIM_WORDS))
191
+ return "reasoning";
192
+ const hasCurrentQualifier = hasAnyWord(q, CURRENT_STATE_CLAIM_WORDS);
193
+ if (hasAnyWord(q, DECISION_CLAIM_WORDS) && !hasCurrentQualifier) {
194
+ return "reasoning";
195
+ }
196
+ if (hasCurrentQualifier)
197
+ return "current_state";
198
+ if (hasAnyWord(q, FRAMING_CLAIM_WORDS))
199
+ return "framing";
200
+ return "current_state";
201
+ }
202
+ function hasAnyPhrase(q, phrases) {
203
+ return phrases.some((p) => q.includes(p));
204
+ }
205
+ function hasAnyWord(q, words) {
206
+ for (const w of words) {
207
+ const rx = new RegExp(`\\b${escapeRx(w)}\\b`, "i");
208
+ if (rx.test(q))
209
+ return true;
210
+ }
211
+ return false;
212
+ }
213
+ function escapeRx(s) {
214
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
215
+ }
216
+ // ─── Freshness / canonicalness classification ───────────────────────────
217
+ /**
218
+ * Default freshness cutoff: a hit indexed within `RECENT_WINDOW_DAYS` of
219
+ * "now" counts as recent. 21 days is wide enough that a paused-but-
220
+ * active project's tracker still reads as current, and narrow enough
221
+ * that an April-26 LAUNCH.md (now ~3 weeks old by 2026-05-19) starts
222
+ * looking stale relative to a 2026-05-19 PROJECT_STATE.md edit.
223
+ */
224
+ export const RECENT_WINDOW_DAYS = 21;
225
+ /**
226
+ * Source-path patterns that mark a hit as a current-truth-shaped
227
+ * artifact — the kind of file you'd open by hand to answer "what's the
228
+ * state right now". Tracker files at the project root, source code,
229
+ * install scripts, and the package manifest all qualify. Matched on
230
+ * basename for the manifests/READMEs (so per-project copies count) and
231
+ * on path segments for src/scripts.
232
+ */
233
+ const TRACKER_PATH_PATTERNS = [
234
+ /(^|\/)PROJECT_STATE\.md$/i,
235
+ /(^|\/)TODO\.md$/i,
236
+ /(^|\/)README\.md$/i,
237
+ /(^|\/)README\.dev\.md$/i,
238
+ /(^|\/)CLAUDE\.md$/i,
239
+ /(^|\/)AGENTS\.md$/i,
240
+ /(^|\/)package\.json$/i,
241
+ /\/src\//,
242
+ /\/scripts\//,
243
+ /(^|\/)config\.json$/i,
244
+ ];
245
+ /**
246
+ * Source-path patterns that mark a hit as a *snapshot* — useful as
247
+ * historical context but not as current truth. Reports/insights are the
248
+ * obvious case (Opus overnight passes), plus filename markers like
249
+ * `-snapshot-`, `-overnight-`, and `-pass-N` that consistently denote
250
+ * frozen syntheses.
251
+ *
252
+ * `LAUNCH.md` is intentionally NOT in this list as of v1: the git-state
253
+ * probe ({@link GitState}) is the general signal — an untracked file is
254
+ * discounted regardless of basename, and removing the hardcoded entry
255
+ * means we don't mis-discount a future `LAUNCH.md` that does get
256
+ * committed to a project as durable launch state.
257
+ */
258
+ const SNAPSHOT_PATH_PATTERNS = [
259
+ /\/reports\/insights\//i,
260
+ /\/reports\/backfill-audit\//i,
261
+ /-snapshot-/i,
262
+ /-overnight-/i,
263
+ /-pass-\d+/i,
264
+ ];
265
+ export function isTrackerPath(p) {
266
+ return typeof p === "string" && TRACKER_PATH_PATTERNS.some((rx) => rx.test(p));
267
+ }
268
+ export function isSnapshotPath(p) {
269
+ return typeof p === "string" && SNAPSHOT_PATH_PATTERNS.some((rx) => rx.test(p));
270
+ }
271
+ /** Live/executable state: source, scripts, manifests, config. */
272
+ const LIVE_STATE_PATH_PATTERNS = [
273
+ /\/src\//,
274
+ /\/scripts\//,
275
+ /(^|\/)package\.json$/i,
276
+ /(^|\/)config\.json$/i,
277
+ /(^|\/)install\.sh$/i,
278
+ ];
279
+ /** Intentional state trackers. */
280
+ const TRACKER_FILE_PATTERNS = [
281
+ /(^|\/)PROJECT_STATE\.md$/i,
282
+ /(^|\/)TODO\.md$/i,
283
+ ];
284
+ /** Durable committed framing docs. */
285
+ const COMMITTED_DOC_PATTERNS = [
286
+ /(^|\/)README(\.[a-z]+)?\.md$/i,
287
+ /(^|\/)CLAUDE\.md$/i,
288
+ /(^|\/)AGENTS\.md$/i,
289
+ /(^|\/)PRD\.md$/i,
290
+ /(^|\/)PLAN\.md$/i,
291
+ ];
292
+ function matchesAny(p, patterns) {
293
+ return patterns.some((rx) => rx.test(p));
294
+ }
295
+ /**
296
+ * Assign a {@link TrustTier} to one evidence input. Pure, structural,
297
+ * claim-type-independent: git-state is the strongest signal (an untracked
298
+ * or deleted document is `discounted` no matter how it's named — the v1
299
+ * generalization of the LAUNCH.md hack), then snapshot paths, then the
300
+ * path-pattern ladder. Conversations are always `discussion`.
301
+ *
302
+ * Defaults to `committed_doc` for a tracked/unknown document that matches
303
+ * none of the specific ladders — it's a durable file we just can't place
304
+ * more precisely, which is a safer default than claiming it's live state.
305
+ */
306
+ export function assignTrustTier(kind, gitState, sourcePath) {
307
+ if (kind === "conversation")
308
+ return "discussion";
309
+ if (gitState === "untracked" || gitState === "deleted")
310
+ return "discounted";
311
+ if (isSnapshotPath(sourcePath))
312
+ return "discounted";
313
+ const p = typeof sourcePath === "string" ? sourcePath : "";
314
+ if (p && matchesAny(p, LIVE_STATE_PATH_PATTERNS))
315
+ return "live_state";
316
+ if (p && matchesAny(p, TRACKER_FILE_PATTERNS))
317
+ return "tracker";
318
+ if (p && matchesAny(p, COMMITTED_DOC_PATTERNS))
319
+ return "committed_doc";
320
+ return "committed_doc";
321
+ }
322
+ /**
323
+ * Parse a row's `indexed_at` and return whether it falls within the
324
+ * recency window. Robust to missing / unparseable timestamps — we'd
325
+ * rather call a row "unknown-age" (treated as not-recent) than blow up
326
+ * the answer packaging.
327
+ */
328
+ export function isRecent(indexedAt, now = new Date(), windowDays = RECENT_WINDOW_DAYS) {
329
+ if (!indexedAt)
330
+ return false;
331
+ const t = Date.parse(indexedAt);
332
+ if (Number.isNaN(t))
333
+ return false;
334
+ const ageMs = now.getTime() - t;
335
+ return ageMs <= windowDays * 24 * 60 * 60 * 1000;
336
+ }
337
+ /** Hard rule 3 caveat string — single source so renderer + DTO agree. */
338
+ export const TRACKER_ONLY_CAVEAT = "tracker-backed, not live-verified";
339
+ /**
340
+ * Snippet cap for evidence cards. Mirrors `SNIPPET_MAX` in context-pack,
341
+ * intentionally duplicated here to keep this module decoupled.
342
+ */
343
+ const EVIDENCE_SNIPPET_MAX = 200;
344
+ export function partitionEvidence(inputs, opts) {
345
+ const cap = opts.maxPerSection;
346
+ const now = opts.now ?? new Date();
347
+ const out = {
348
+ current_truth: [],
349
+ past_reasoning: [],
350
+ older_memory: [],
351
+ discounted: [],
352
+ recommended_live_files: [],
353
+ conflicts: [],
354
+ };
355
+ // Track tracker-paths we encounter so the recommended_live_files fallback
356
+ // can prefer paths the index already knows about. Value is the
357
+ // pre-probed git state (if any) so the recommendation card can echo
358
+ // it back to the asker (e.g. "newer on disk than indexed copy").
359
+ const seenTrackerPaths = new Map();
360
+ // Tracker paths surfaced in hits with a live git state — eligible for
361
+ // promotion to `recommended_live_files`. We exclude `untracked` and
362
+ // `deleted` here: recommending a file the asker can't open or that
363
+ // isn't under version control would defeat the point.
364
+ const recommendableGitStates = new Set([
365
+ "tracked",
366
+ "modified",
367
+ "newer_on_disk",
368
+ undefined,
369
+ ]);
370
+ // Basenames of paths the partitioner discounted as untracked or
371
+ // deleted. The canonical fallback list is hand-written basenames
372
+ // (`PROJECT_STATE.md`, `TODO.md`, `package.json`) and was probed
373
+ // alongside the rest of the hits — so if any of those basenames
374
+ // were already discounted, the recommendation must drop them
375
+ // rather than re-suggest them. Tracked via basename because the
376
+ // canonical fallback doesn't have absolute paths to match against.
377
+ const discountedBasenames = new Set();
378
+ for (const input of inputs) {
379
+ const { row, kind, gitState } = input;
380
+ const sourcePath = row?.["source_path"] || undefined;
381
+ const indexedAt = row?.["indexed_at"] || undefined;
382
+ const recent = isRecent(indexedAt, now);
383
+ const tracker = isTrackerPath(sourcePath);
384
+ const snapshot = isSnapshotPath(sourcePath);
385
+ const card = buildEvidenceCard(input);
386
+ if (!card)
387
+ continue;
388
+ if (tracker &&
389
+ sourcePath &&
390
+ recommendableGitStates.has(gitState)) {
391
+ seenTrackerPaths.set(sourcePath, gitState);
392
+ }
393
+ if (opts.intent === "reasoning_archive") {
394
+ if (kind === "conversation") {
395
+ pushIfRoom(out.past_reasoning, card, cap);
396
+ }
397
+ else if (tracker) {
398
+ pushIfRoom(out.current_truth, card, cap);
399
+ }
400
+ else if (snapshot) {
401
+ pushIfRoom(out.older_memory, card, cap);
402
+ }
403
+ else {
404
+ pushIfRoom(out.older_memory, card, cap);
405
+ }
406
+ continue;
407
+ }
408
+ // v1 git-state discounts. Apply ONLY to documents and ONLY in
409
+ // current-truth-shaped modes (current_truth + blended). Conversations
410
+ // don't have a meaningful git state, and reasoning_archive mode
411
+ // (handled above) cares about framing, not canonicalness.
412
+ if (kind === "document" && gitState === "deleted") {
413
+ pushIfRoom(out.discounted, withReason(card, "deleted from disk"), cap);
414
+ if (sourcePath)
415
+ discountedBasenames.add(basename(sourcePath));
416
+ continue;
417
+ }
418
+ if (kind === "document" && gitState === "untracked") {
419
+ pushIfRoom(out.discounted, withReason(card, "untracked in git (not committed)"), cap);
420
+ if (sourcePath)
421
+ discountedBasenames.add(basename(sourcePath));
422
+ continue;
423
+ }
424
+ if (opts.intent === "current_truth") {
425
+ // v2 hard rule 1: conversations can NEVER enter `current_truth` on
426
+ // recency alone. This is the load-bearing fix — it breaks the
427
+ // `untrusted source → recent conversation → current_truth` laundering
428
+ // loop (and its self-corroboration variant where a captured Rift
429
+ // answer re-enters as evidence). Recent conversations surface as
430
+ // `past_reasoning` ("recent discussion"); older ones as `older_memory`.
431
+ // Demote-by-default: a conversation is never sole authority here.
432
+ if (kind === "conversation") {
433
+ pushIfRoom(recent ? out.past_reasoning : out.older_memory, card, cap);
434
+ }
435
+ else if (tracker) {
436
+ pushIfRoom(out.current_truth, card, cap);
437
+ }
438
+ else if (snapshot) {
439
+ pushIfRoom(out.discounted, withReason(card, "snapshot or insight report"), cap);
440
+ }
441
+ else if (!recent) {
442
+ pushIfRoom(out.discounted, withReason(card, "older document"), cap);
443
+ }
444
+ else {
445
+ pushIfRoom(out.current_truth, card, cap);
446
+ }
447
+ continue;
448
+ }
449
+ // blended
450
+ if (tracker) {
451
+ pushIfRoom(out.current_truth, card, cap);
452
+ }
453
+ else if (kind === "conversation") {
454
+ pushIfRoom(out.past_reasoning, card, cap);
455
+ }
456
+ else if (snapshot) {
457
+ pushIfRoom(out.discounted, withReason(card, "snapshot or insight report"), cap);
458
+ }
459
+ else if (recent) {
460
+ pushIfRoom(out.current_truth, card, cap);
461
+ }
462
+ else {
463
+ pushIfRoom(out.older_memory, card, cap);
464
+ }
465
+ }
466
+ // v2 hard rule 5 (staleness demotion): a tracker is *not* absolute. If
467
+ // `current_truth` holds a `live_state`-tier card and a tracker/
468
+ // committed_doc card is older than that live evidence by more than the
469
+ // recency window, the tracker is stale relative to live activity —
470
+ // demote it to `older_memory` (tier label retained so the demotion is
471
+ // visible). Runs before the recommended-live-files check so an emptied
472
+ // `current_truth` correctly triggers the "read the live files" fallback.
473
+ if (opts.intent !== "reasoning_archive") {
474
+ applyStalenessDemotion(out, now, cap);
475
+ }
476
+ // Recommended live files: only for current_truth and blended, and only
477
+ // when `current_truth` is empty or so thin that the asker should read
478
+ // the live files before trusting the answer. The list is sourced from
479
+ // tracker paths we already saw in hits (so we're naming real, indexed
480
+ // files), with a small set of canonical fallbacks if none surfaced.
481
+ if (opts.intent !== "reasoning_archive") {
482
+ const dominatedByOlder = out.current_truth.length === 0 &&
483
+ (out.discounted.length > 0 || out.older_memory.length > 0);
484
+ const veryThin = out.current_truth.length === 0;
485
+ if (dominatedByOlder || veryThin) {
486
+ out.recommended_live_files = recommendLiveFiles(seenTrackerPaths, opts.projectRoot, discountedBasenames);
487
+ }
488
+ }
489
+ // v2 hard rule 3 (caveat): `current_truth` is populated but holds no
490
+ // `live_state`-tier evidence — the best we have is an intentional
491
+ // tracker, not executable/observable state. Label it honestly.
492
+ if (opts.intent !== "reasoning_archive" &&
493
+ out.current_truth.length > 0 &&
494
+ !out.current_truth.some((c) => c.tier === "live_state")) {
495
+ out.current_truth_caveat = TRACKER_ONLY_CAVEAT;
496
+ }
497
+ // v2 hard rule 4 (surface conflicts): a discounted current-state-shaped
498
+ // source competing with higher-tier evidence is a disagreement, not a
499
+ // loser to silently drop. Name the tension; do not adjudicate it.
500
+ if (opts.intent !== "reasoning_archive") {
501
+ out.conflicts = buildConflicts(out, opts.claimType ?? "current_state");
502
+ }
503
+ return out;
504
+ }
505
+ /**
506
+ * Move stale trackers out of `current_truth` (hard rule 5). A tracker /
507
+ * committed_doc card older than the freshest `live_state` card by more
508
+ * than {@link RECENT_WINDOW_DAYS} is demoted to `older_memory`. No-op
509
+ * when `current_truth` has no live-state anchor to compare against —
510
+ * without live evidence we have no basis to call a tracker stale.
511
+ *
512
+ * P1 fix: the demotable card's age is its *self-declared* freshness
513
+ * (`tracker_date`, e.g. PROJECT_STATE.md's `Last touched:`) when present,
514
+ * not `indexed_at`. A tracker reindexed today but whose own date is weeks
515
+ * old must still be demotable — `indexed_at` would mask that.
516
+ */
517
+ function applyStalenessDemotion(out, now, cap) {
518
+ void now;
519
+ const liveTimes = out.current_truth
520
+ .filter((c) => c.tier === "live_state" && c.timestamp)
521
+ .map((c) => Date.parse(c.timestamp))
522
+ .filter((t) => !Number.isNaN(t));
523
+ if (liveTimes.length === 0)
524
+ return;
525
+ const freshestLive = Math.max(...liveTimes);
526
+ const windowMs = RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1000;
527
+ const kept = [];
528
+ for (const c of out.current_truth) {
529
+ const demotable = c.tier === "tracker" || c.tier === "committed_doc";
530
+ const anchor = c.tracker_date ?? c.timestamp;
531
+ const t = anchor ? Date.parse(anchor) : NaN;
532
+ if (demotable && !Number.isNaN(t) && freshestLive - t > windowMs) {
533
+ pushIfRoom(out.older_memory, c, cap);
534
+ continue;
535
+ }
536
+ kept.push(c);
537
+ }
538
+ out.current_truth = kept;
539
+ }
540
+ /**
541
+ * Build at most one conflict card (v2 restraint — surface the strongest
542
+ * tension, not every stale doc). Fires when a discounted source exists
543
+ * and there's higher-trust evidence to supersede it: the top
544
+ * `current_truth` card, else the first recommended live file. Returns []
545
+ * when there's nothing discounted, or nothing trustworthy to supersede
546
+ * it (in which case the honest answer is the recommended-live-files
547
+ * surface, not a manufactured conflict).
548
+ */
549
+ function buildConflicts(out, claim) {
550
+ const loser = out.discounted[0];
551
+ if (!loser)
552
+ return [];
553
+ const winnerCard = out.current_truth[0];
554
+ if (winnerCard) {
555
+ const superseded = {
556
+ title: winnerCard.title,
557
+ tier: winnerCard.tier ?? "committed_doc",
558
+ };
559
+ if (winnerCard.source_path)
560
+ superseded.source_path = winnerCard.source_path;
561
+ return [conflictCard(claim, loser, superseded)];
562
+ }
563
+ const winnerFile = out.recommended_live_files[0];
564
+ if (winnerFile) {
565
+ const superseded = {
566
+ title: winnerFile.path,
567
+ tier: assignTrustTier("document", winnerFile.git_state, winnerFile.path),
568
+ source_path: winnerFile.path,
569
+ };
570
+ return [conflictCard(claim, loser, superseded)];
571
+ }
572
+ return [];
573
+ }
574
+ function conflictCard(claim, loser, superseded_by) {
575
+ const discounted = {
576
+ title: loser.title,
577
+ reason: loser.reason,
578
+ };
579
+ if (loser.source_path)
580
+ discounted.source_path = loser.source_path;
581
+ return { claim, discounted, superseded_by };
582
+ }
583
+ function pushIfRoom(arr, item, cap) {
584
+ if (arr.length >= cap)
585
+ return;
586
+ arr.push(item);
587
+ }
588
+ function withReason(card, reason) {
589
+ return { ...card, reason };
590
+ }
591
+ function buildEvidenceCard(input) {
592
+ const { ranked, row, kind, gitState } = input;
593
+ const fullContent = row?.["content"] || ranked.content || "";
594
+ const summary = row?.["summary"] ||
595
+ fullContent.split(/\r?\n/).find((l) => l.trim()) ||
596
+ "";
597
+ const snippet = truncate(collapseWhitespace(summary), EVIDENCE_SNIPPET_MAX);
598
+ if (!snippet)
599
+ return null;
600
+ const sourcePath = row?.["source_path"] || undefined;
601
+ const indexedAt = row?.["indexed_at"] || undefined;
602
+ const source = row?.["source"] || (kind === "document" ? "document" : "conversation");
603
+ const title = deriveTitle(row, sourcePath, snippet);
604
+ const card = {
605
+ id: ranked.id,
606
+ title,
607
+ source,
608
+ snippet,
609
+ };
610
+ if (indexedAt)
611
+ card.timestamp = indexedAt;
612
+ if (sourcePath)
613
+ card.source_path = sourcePath;
614
+ if (gitState)
615
+ card.git_state = gitState;
616
+ card.tier = assignTrustTier(kind, gitState, sourcePath);
617
+ const trackerDate = parseTrackerDate(fullContent);
618
+ if (trackerDate)
619
+ card.tracker_date = trackerDate;
620
+ return card;
621
+ }
622
+ /**
623
+ * Best-effort parse of a tracker's self-declared freshness from its
624
+ * content. Currently the `Last touched: YYYY-MM-DD` line that
625
+ * PROJECT_STATE.md carries near the top. Returns the matched ISO date
626
+ * string, or undefined when the chunk doesn't contain the marker (the
627
+ * caller falls back to `indexed_at`). Tolerates optional markdown
628
+ * bold/list decoration around the label.
629
+ */
630
+ export function parseTrackerDate(content) {
631
+ if (!content)
632
+ return undefined;
633
+ const m = content.match(/(?:^|\n)\s*(?:[-*]\s*)?(?:\*\*)?Last touched:?(?:\*\*)?\s*:?\s*(\d{4}-\d{2}-\d{2})/i);
634
+ return m ? m[1] : undefined;
635
+ }
636
+ function deriveTitle(row, sourcePath, snippet) {
637
+ const meta = row?.["metadata"];
638
+ if (typeof meta === "string" && meta) {
639
+ try {
640
+ const parsed = JSON.parse(meta);
641
+ const t = parsed["title"];
642
+ if (typeof t === "string" && t.trim())
643
+ return truncate(t.trim(), 120);
644
+ }
645
+ catch {
646
+ // ignore
647
+ }
648
+ }
649
+ if (sourcePath) {
650
+ const base = sourcePath.split("/").filter(Boolean).pop();
651
+ if (base)
652
+ return truncate(base, 120);
653
+ }
654
+ const topics = row?.["topics"];
655
+ if (typeof topics === "string" && topics) {
656
+ try {
657
+ const arr = JSON.parse(topics);
658
+ if (Array.isArray(arr) && arr.length > 0) {
659
+ return truncate(arr.filter((x) => typeof x === "string").slice(0, 3).join(", "), 120);
660
+ }
661
+ }
662
+ catch {
663
+ // ignore
664
+ }
665
+ }
666
+ return truncate(firstLine(snippet) || "(item)", 120);
667
+ }
668
+ function firstLine(text) {
669
+ for (const line of text.split(/\r?\n/)) {
670
+ const t = line.trim();
671
+ if (t)
672
+ return t.replace(/^#+\s*/, "").replace(/^[-*]\s+/, "");
673
+ }
674
+ return "";
675
+ }
676
+ function collapseWhitespace(text) {
677
+ return text.replace(/\s+/g, " ").trim();
678
+ }
679
+ function truncate(text, max) {
680
+ if (text.length <= max)
681
+ return text;
682
+ return text.slice(0, max);
683
+ }
684
+ function basename(p) {
685
+ const parts = p.split("/").filter(Boolean);
686
+ return parts[parts.length - 1] ?? p;
687
+ }
688
+ /**
689
+ * Canonical fallback list when the asker is in current_truth/blended
690
+ * mode and we have *no* tracker hits to point at. These are the files a
691
+ * human would open first on this project to check the present-tense
692
+ * state of things — naming them by hand here is honest because the
693
+ * caller hasn't actually read them; the phrasing in the markdown
694
+ * renderer reflects that ("Before trusting this answer, read/check …").
695
+ */
696
+ const CANONICAL_LIVE_FILES = [
697
+ { path: "PROJECT_STATE.md", why: "current focus and recent decisions" },
698
+ { path: "TODO.md", why: "active work" },
699
+ { path: "package.json", why: "current version" },
700
+ ];
701
+ function recommendLiveFiles(seen, projectRoot, discountedBasenames) {
702
+ if (seen.size > 0) {
703
+ const out = [];
704
+ for (const [p, gs] of seen) {
705
+ const why = describeTrackerPath(p);
706
+ const card = { path: displayPath(p, projectRoot), why };
707
+ if (gs)
708
+ card.git_state = gs;
709
+ out.push(card);
710
+ if (out.length >= 3)
711
+ break;
712
+ }
713
+ return out;
714
+ }
715
+ // Canonical fallback. Drop any entry whose basename was already
716
+ // discounted as untracked/deleted in this pack — the partitioner
717
+ // probed it, decided not to trust it, and the fallback would
718
+ // otherwise re-recommend it unprobed.
719
+ return CANONICAL_LIVE_FILES.filter((f) => !discountedBasenames.has(basename(f.path))).map((f) => ({ ...f }));
720
+ }
721
+ function describeTrackerPath(p) {
722
+ if (/PROJECT_STATE\.md$/i.test(p))
723
+ return "current focus and recent decisions";
724
+ if (/TODO\.md$/i.test(p))
725
+ return "active work";
726
+ if (/package\.json$/i.test(p))
727
+ return "current version";
728
+ if (/README/i.test(p))
729
+ return "stated install / usage";
730
+ if (/CLAUDE\.md$/i.test(p))
731
+ return "project conventions";
732
+ if (/AGENTS\.md$/i.test(p))
733
+ return "agent contract";
734
+ if (/\/src\//.test(p))
735
+ return "live source";
736
+ if (/\/scripts\//.test(p))
737
+ return "live script";
738
+ return "live file";
739
+ }
740
+ function displayPath(p, projectRoot) {
741
+ if (projectRoot && p.startsWith(projectRoot)) {
742
+ const rel = p.slice(projectRoot.length).replace(/^\//, "");
743
+ return rel || p;
744
+ }
745
+ return p;
746
+ }
747
+ //# sourceMappingURL=current-truth.js.map