@phren/cli 0.0.3 → 0.0.5

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.
@@ -16,7 +16,7 @@ const SEARCH_TYPES = new Set([
16
16
  "task",
17
17
  "changelog",
18
18
  "canonical",
19
- "memory-queue",
19
+ "review-queue",
20
20
  "skill",
21
21
  "other",
22
22
  ]);
@@ -45,7 +45,7 @@ function printSearchUsage() {
45
45
  console.error(" phren search --project <name> [--type <type>] [--limit <n>] [--all]");
46
46
  console.error(" phren search --history Show recent searches");
47
47
  console.error(" phren search --from-history <n> Re-run search #n from history");
48
- console.error(" type: claude|summary|findings|reference|task|changelog|canonical|memory-queue|skill|other");
48
+ console.error(" type: claude|summary|findings|reference|task|changelog|canonical|review-queue|skill|other");
49
49
  }
50
50
  function validateAndNormalizeSearchOptions(phrenPath, queryParts, project, type, limit, showHistory, fromHistory, searchAll) {
51
51
  if (showHistory) {
@@ -1,9 +1,11 @@
1
1
  import * as fs from "fs";
2
+ import { statSync } from "fs";
2
3
  import * as path from "path";
3
4
  import { debugLog, EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS } from "./shared.js";
4
5
  import { errorMessage, runGitOrThrow } from "./utils.js";
5
6
  import { findingIdFromLine } from "./finding-impact.js";
6
7
  import { METADATA_REGEX, isArchiveStart, isArchiveEnd } from "./content-metadata.js";
8
+ import { FINDING_TYPE_DECAY, extractFindingType } from "./finding-lifecycle.js";
7
9
  export const FINDING_PROVENANCE_SOURCES = [
8
10
  "human",
9
11
  "agent",
@@ -270,6 +272,16 @@ function confidenceForAge(ageDays, decay) {
270
272
  return d90 - ((d90 - d120) * ((ageDays - 90) / 30));
271
273
  return d120; // don't decay further past d120; TTL handles final expiry
272
274
  }
275
+ function wasFileModifiedAfter(filePath, findingDate) {
276
+ try {
277
+ const stat = statSync(filePath);
278
+ const fileModified = stat.mtime.toISOString().slice(0, 10);
279
+ return fileModified > findingDate;
280
+ }
281
+ catch {
282
+ return false; // File doesn't exist or can't stat — handled by citation validation
283
+ }
284
+ }
273
285
  export function filterTrustedFindings(content, ttlDays) {
274
286
  return filterTrustedFindingsDetailed(content, { ttlDays }).content;
275
287
  }
@@ -356,11 +368,41 @@ export function filterTrustedFindingsDetailed(content, opts) {
356
368
  else {
357
369
  confidence = DEFAULT_UNDATED_CONFIDENCE;
358
370
  }
371
+ // Type-specific decay adjustment
372
+ const findingType = extractFindingType(line);
373
+ if (findingType) {
374
+ const typeConfig = FINDING_TYPE_DECAY[findingType];
375
+ if (typeConfig) {
376
+ // Override max age for this type
377
+ if (effectiveDate && typeConfig.maxAgeDays !== Infinity) {
378
+ const age = ageDaysForDate(effectiveDate);
379
+ if (age !== null && age > typeConfig.maxAgeDays) {
380
+ issues.push({ date: effectiveDate || "unknown", bullet: line, reason: "stale" });
381
+ if (citation)
382
+ i++;
383
+ continue;
384
+ }
385
+ }
386
+ // Apply type-specific decay multiplier
387
+ confidence *= typeConfig.decayMultiplier;
388
+ // Decisions and anti-patterns get a floor boost (never drop below 0.6)
389
+ if (typeConfig.maxAgeDays === Infinity) {
390
+ confidence = Math.max(confidence, 0.6);
391
+ }
392
+ }
393
+ }
359
394
  if (citation && !validateFindingCitation(citation)) {
360
395
  issues.push({ date: effectiveDate || "unknown", bullet: line, reason: "invalid_citation" });
361
396
  i++;
362
397
  continue;
363
398
  }
399
+ // If cited file was modified after finding was created, lower confidence
400
+ if (citation && effectiveDate && citation.file) {
401
+ const fileModifiedAfterFinding = wasFileModifiedAfter(citation.file, effectiveDate);
402
+ if (fileModifiedAfterFinding) {
403
+ confidence *= 0.7; // File changed since finding was written — may be stale
404
+ }
405
+ }
364
406
  if (!citation)
365
407
  confidence *= 0.8;
366
408
  const provenance = parseSourceComment(line)?.source ?? "unknown";
@@ -187,7 +187,7 @@ export function jaccardSimilarity(a, b) {
187
187
  const PROSE_ENTITY_RE = UNIVERSAL_TECH_TERMS_RE;
188
188
  const POSITIVE_RE = /\b(always|prefer|should|must|works|recommend|enable)\b/i;
189
189
  const NEGATIVE_RE = /\b(never|avoid|don't|do not|shouldn't|must not|broken|deprecated|disable)\b/i;
190
- // ── Dynamic entity extraction ─────────────────────────────────────────────────
190
+ // ── Dynamic fragment extraction ────────────────────────────────────────────────
191
191
  const ENTITY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
192
192
  // Patterns that suggest a token is a proper noun / tool name:
193
193
  // - CamelCase word (at least one interior uppercase): PhotonMappingEngine, GameKit
@@ -257,7 +257,7 @@ function extractProseEntities(text, dynamicEntities) {
257
257
  let m;
258
258
  while ((m = re.exec(text)) !== null)
259
259
  found.add(m[0].toLowerCase());
260
- // Match additional entity patterns (versions, env keys, file paths, error codes, dates)
260
+ // Match additional fragment patterns (versions, env keys, file paths, error codes, dates)
261
261
  for (const { re: pattern } of EXTRA_ENTITY_PATTERNS) {
262
262
  const pRe = new RegExp(pattern.source, pattern.flags);
263
263
  let pm;
@@ -265,7 +265,7 @@ function extractProseEntities(text, dynamicEntities) {
265
265
  found.add(pm[0].toLowerCase());
266
266
  }
267
267
  if (dynamicEntities) {
268
- // Also check whether any dynamic entity appears (case-insensitive word match)
268
+ // Also check whether any dynamic fragment appears (case-insensitive word match)
269
269
  for (const entity of dynamicEntities) {
270
270
  const escaped = entity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
271
271
  if (new RegExp(`\\b${escaped}\\b`, "i").test(text)) {
@@ -204,20 +204,20 @@ export function upsertCanonical(phrenPath, project, memory) {
204
204
  const resolvedDir = safeProjectPath(phrenPath, project);
205
205
  if (!resolvedDir || !fs.existsSync(resolvedDir))
206
206
  return phrenErr(`Project "${project}" not found in phren.`, PhrenError.PROJECT_NOT_FOUND);
207
- const canonicalPath = path.join(resolvedDir, "CANONICAL_MEMORIES.md");
207
+ const canonicalPath = path.join(resolvedDir, "truths.md");
208
208
  const today = new Date().toISOString().slice(0, 10);
209
209
  const bullet = memory.startsWith("- ") ? memory : `- ${memory}`;
210
210
  withFileLock(canonicalPath, () => {
211
211
  if (!fs.existsSync(canonicalPath)) {
212
- fs.writeFileSync(canonicalPath, `# ${project} Canonical Memories\n\n## Pinned\n\n${bullet} _(pinned ${today})_\n`);
212
+ fs.writeFileSync(canonicalPath, `# ${project} Truths\n\n## Truths\n\n${bullet} _(added ${today})_\n`);
213
213
  }
214
214
  else {
215
215
  const existing = fs.readFileSync(canonicalPath, "utf8");
216
- const line = `${bullet} _(pinned ${today})_`;
216
+ const line = `${bullet} _(added ${today})_`;
217
217
  if (!existing.includes(bullet)) {
218
- const updated = existing.includes("## Pinned")
219
- ? existing.replace("## Pinned", `## Pinned\n\n${line}`)
220
- : `${existing.trimEnd()}\n\n## Pinned\n\n${line}\n`;
218
+ const updated = existing.includes("## Truths")
219
+ ? existing.replace("## Truths", `## Truths\n\n${line}`)
220
+ : `${existing.trimEnd()}\n\n## Truths\n\n${line}\n`;
221
221
  const content = updated.endsWith("\n") ? updated : updated + "\n";
222
222
  const tmpPath = canonicalPath + `.tmp-${crypto.randomUUID()}`;
223
223
  fs.writeFileSync(tmpPath, content);
@@ -226,7 +226,7 @@ export function upsertCanonical(phrenPath, project, memory) {
226
226
  }
227
227
  });
228
228
  appendAuditLog(phrenPath, "pin_memory", `project=${project} memory=${JSON.stringify(memory)}`);
229
- return phrenOk(`Pinned canonical memory in ${project}.`);
229
+ return phrenOk(`Truth saved in ${project}.`);
230
230
  }
231
231
  export function addFindingToFile(phrenPath, project, learning, citationInput, opts) {
232
232
  const findingError = validateFinding(learning);
@@ -31,7 +31,7 @@ Usage:
31
31
  phren search <query> [--project <n>] [--type <t>] [--limit <n>]
32
32
  Search what phren remembers
33
33
  phren add-finding <project> "..." Tell phren what you learned
34
- phren pin <project> "..." Pin a canonical memory
34
+ phren pin <project> "..." Save a truth
35
35
  phren tasks Cross-project task view
36
36
  phren skill-list List installed skills
37
37
  phren doctor [--fix] [--check-data] [--agents]
@@ -6,6 +6,24 @@ const LIFECYCLE_PREFIX = "phren";
6
6
  import { withFileLock } from "./governance-locks.js";
7
7
  import { isValidProjectName, safeProjectPath } from "./utils.js";
8
8
  import { parseCreatedDate as parseCreatedDateMeta, parseStatusField, parseStatus, parseSupersession, parseContradiction, parseFindingId as parseFindingIdMeta, stripLifecycleMetadata, stripRelationMetadata, } from "./content-metadata.js";
9
+ export const FINDING_TYPE_DECAY = {
10
+ 'pattern': { maxAgeDays: 365, decayMultiplier: 1.0 }, // Slow decay, long-lived
11
+ 'decision': { maxAgeDays: Infinity, decayMultiplier: 1.0 }, // Never decays
12
+ 'pitfall': { maxAgeDays: 365, decayMultiplier: 1.0 }, // Slow decay
13
+ 'anti-pattern': { maxAgeDays: Infinity, decayMultiplier: 1.0 }, // Never decays
14
+ 'observation': { maxAgeDays: 14, decayMultiplier: 0.7 }, // Fast decay, short-lived
15
+ 'workaround': { maxAgeDays: 60, decayMultiplier: 0.85 }, // Medium decay
16
+ 'bug': { maxAgeDays: 30, decayMultiplier: 0.8 }, // Medium-fast decay
17
+ 'tooling': { maxAgeDays: 180, decayMultiplier: 0.95 }, // Medium-slow decay
18
+ 'context': { maxAgeDays: 30, decayMultiplier: 0.75 }, // Fast decay (contextual facts)
19
+ };
20
+ export function extractFindingType(line) {
21
+ const match = line.match(/\[(\w[\w-]*)\]/);
22
+ if (!match)
23
+ return null;
24
+ const tag = match[1].toLowerCase();
25
+ return tag in FINDING_TYPE_DECAY ? tag : null;
26
+ }
9
27
  export const FINDING_LIFECYCLE_STATUSES = [
10
28
  "active",
11
29
  "superseded",
@@ -387,7 +387,7 @@ export function appendReviewQueue(phrenPath, project, section, entries) {
387
387
  const resolvedDir = safeProjectPath(phrenPath, project);
388
388
  if (!resolvedDir || !fs.existsSync(resolvedDir))
389
389
  return phrenErr(`Project "${project}" not found in phren.`, PhrenError.PROJECT_NOT_FOUND);
390
- const queuePath = path.join(resolvedDir, "MEMORY_QUEUE.md");
390
+ const queuePath = path.join(resolvedDir, "review.md");
391
391
  const today = new Date().toISOString().slice(0, 10);
392
392
  const normalized = [];
393
393
  for (const entry of entries) {
@@ -270,7 +270,7 @@ export function recordInjection(phrenPath, key, sessionId) {
270
270
  debugLog(`Usage log rotation failed: ${errorMessage(err)}`);
271
271
  }
272
272
  }
273
- export function recordFeedback(phrenPath, key, feedback) {
273
+ export function recordFeedback(phrenPath, key, feedback, sessionId) {
274
274
  const delta = {};
275
275
  if (feedback === "helpful")
276
276
  delta.helpful = 1;
@@ -280,6 +280,14 @@ export function recordFeedback(phrenPath, key, feedback) {
280
280
  delta.regressionPenalty = 1;
281
281
  appendScoreJournal(phrenPath, key, delta);
282
282
  appendAuditLog(phrenPath, "memory_feedback", `key=${key} feedback=${feedback}`);
283
+ // When feedback is "helpful", mark correlated query entries for future boost
284
+ if (feedback === "helpful" && sessionId) {
285
+ import("./query-correlation.js").then(({ markCorrelationsHelpful: markHelpful }) => {
286
+ const colonIdx = key.indexOf(":");
287
+ const docKey = colonIdx >= 0 ? key.slice(0, colonIdx) : key;
288
+ markHelpful(phrenPath, sessionId, docKey);
289
+ }).catch(() => { });
290
+ }
283
291
  }
284
292
  // Module-level cache for the journal aggregation used by getQualityMultiplier.
285
293
  // Invalidated whenever flushEntryScores runs (at which point the journal is cleared).
@@ -34,7 +34,7 @@ export function updateFileChecksums(phrenPath, profileName) {
34
34
  const tracked = [];
35
35
  const dirs = getProjectDirs(phrenPath, profileName);
36
36
  for (const dir of dirs) {
37
- for (const name of ["FINDINGS.md", ...TASK_FILE_ALIASES, "CANONICAL_MEMORIES.md"]) {
37
+ for (const name of ["FINDINGS.md", ...TASK_FILE_ALIASES, "truths.md"]) {
38
38
  const full = path.join(dir, name);
39
39
  if (!fs.existsSync(full))
40
40
  continue;
@@ -533,7 +533,7 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
533
533
  const projectName = path.basename(projectDir);
534
534
  if (projectName === "global")
535
535
  continue;
536
- for (const mdFile of ["FINDINGS.md", ...TASK_FILE_ALIASES, "MEMORY_QUEUE.md", "CLAUDE.md", "REFERENCE.md"]) {
536
+ for (const mdFile of ["FINDINGS.md", ...TASK_FILE_ALIASES, "review.md", "CLAUDE.md", "REFERENCE.md"]) {
537
537
  const filePath = path.join(projectDir, mdFile);
538
538
  if (!fs.existsSync(filePath))
539
539
  continue;
@@ -10,10 +10,10 @@ export function register(server, ctx) {
10
10
  const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
11
11
  server.registerTool("pin_memory", {
12
12
  title: "◆ phren · pin memory",
13
- description: "Promote an important memory into CANONICAL_MEMORIES.md so retrieval prioritizes it.",
13
+ description: "Write a truth a high-confidence, always-inject entry in truths.md that never decays.",
14
14
  inputSchema: z.object({
15
15
  project: z.string().describe("Project name."),
16
- memory: z.string().describe("Canonical memory text to pin."),
16
+ memory: z.string().describe("Truth text."),
17
17
  }),
18
18
  }, async ({ project, memory }) => {
19
19
  if (!isValidProjectName(project))
@@ -22,8 +22,8 @@ export function register(server, ctx) {
22
22
  const result = upsertCanonical(phrenPath, project, memory);
23
23
  if (!result.ok)
24
24
  return mcpResponse({ ok: false, error: result.error });
25
- // Update FTS index so newly pinned memory is immediately searchable
26
- const canonicalPath = path.join(phrenPath, project, "CANONICAL_MEMORIES.md");
25
+ // Update FTS index so newly added truth is immediately searchable
26
+ const canonicalPath = path.join(phrenPath, project, "truths.md");
27
27
  updateFileInIndex(canonicalPath);
28
28
  return mcpResponse({ ok: true, message: result.data, data: { project, memory } });
29
29
  });
@@ -294,7 +294,7 @@ export function register(server, ctx) {
294
294
  // ── get_review_queue ─────────────────────────────────────────────────────
295
295
  server.registerTool("get_review_queue", {
296
296
  title: "◆ phren · get review queue",
297
- description: "List all items in a project's memory review queue (MEMORY_QUEUE.md), or across all projects when omitted. " +
297
+ description: "List all items in a project's review queue (review.md), or across all projects when omitted. " +
298
298
  "Returns items with their id, section (Review/Stale/Conflicts), date, text, confidence, and risky flag.",
299
299
  inputSchema: z.object({
300
300
  project: z.string().optional().describe("Project name. Omit to read the review queue across all projects in the active profile."),
@@ -264,7 +264,7 @@ export async function buildGraph(phrenPath, profile, focusProject) {
264
264
  catch {
265
265
  // task loading failed — continue with other data sources
266
266
  }
267
- // ── Fragments (entity graph) ───────────────────────────────────────
267
+ // ── Fragments (fragment graph) ──────────────────────────────────────
268
268
  let db = null;
269
269
  try {
270
270
  db = await buildIndex(phrenPath, profile);
@@ -326,7 +326,7 @@ export async function buildGraph(phrenPath, profile, focusProject) {
326
326
  entityType: type,
327
327
  refDocs: refs,
328
328
  });
329
- // Link entity to each project it appears in
329
+ // Link fragment to each project it appears in
330
330
  const linkedProjects = new Set();
331
331
  for (const ref of refs) {
332
332
  if (ref.project && projectSet.has(ref.project))
@@ -339,7 +339,7 @@ export async function buildGraph(phrenPath, profile, focusProject) {
339
339
  }
340
340
  }
341
341
  catch {
342
- // entity loading failed — continue with other data sources
342
+ // fragment loading failed — continue with other data sources
343
343
  }
344
344
  finally {
345
345
  if (db) {
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Phren character ASCII/Unicode art and spinner for CLI presence.
2
+ * Phren character ASCII/Unicode art, animation engine, and spinner for CLI presence.
3
3
  *
4
4
  * Based on the pixel art: purple 8-bit brain with diamond eyes,
5
5
  * smile, little legs, and cyan sparkle.
6
+ *
7
+ * Animation system provides lifelike movement through composable effects:
8
+ * bob, blink, sparkle, and lean — all driven by setTimeout with randomized
9
+ * intervals for organic timing.
6
10
  */
7
11
  const ESC = "\x1b[";
8
12
  const RESET = `${ESC}0m`;
@@ -14,6 +18,7 @@ const DARK_PURPLE = `${ESC}38;5;57m`; // deep purple — shadow/outline
14
18
  const LIGHT_PURPLE = `${ESC}38;5;141m`; // lavender — brain highlights
15
19
  const MID_PURPLE = `${ESC}38;5;98m`; // mid tone
16
20
  const NAVY = `${ESC}38;5;18m`; // darkest outline
21
+ // ── Art constants (24px wide, truecolor half-blocks) ─────────────────────────
17
22
  /**
18
23
  * Phren truecolor art (24px wide, generated from phren-transparent.png).
19
24
  * Uses half-block ▀ with RGB foreground+background for pixel-faithful rendering.
@@ -33,6 +38,268 @@ export const PHREN_ART = [
33
38
  " ",
34
39
  " ",
35
40
  ];
41
+ /** The art width in visible columns */
42
+ const ART_WIDTH = 24;
43
+ // ── Sparkle row: the cyan pixels at row 2 ────────────────────────────────────
44
+ // Sparkle uses ▄ half-blocks with cyan truecolor. For animation we cycle through
45
+ // decorative unicode characters at different brightness levels.
46
+ const SPARKLE_ROW = 2;
47
+ const SPARKLE_CHARS = ["\u2726", "\u2727", "\u2736", " "]; // ✦ ✧ ✶ (dim/blank)
48
+ // ── Eye detection: dark navy pixels in row 6 (fg R<30, G<45, B<120) ──────────
49
+ // Row 6 has two eye pixels at segments 1 and 5 (visual positions 7 and 11).
50
+ // When blinking, we replace their dark fg color with the surrounding body purple.
51
+ const EYE_ROW = 6;
52
+ // Dark-pixel fg threshold
53
+ const EYE_R_MAX = 30;
54
+ const EYE_G_MAX = 45;
55
+ const EYE_B_MAX = 120;
56
+ // Body-purple color to use when "closing" eyes (average of surrounding pixels)
57
+ const BLINK_COLOR = "146;130;250";
58
+ /**
59
+ * Flip a single art line horizontally. Reverses the order of colored pixel
60
+ * segments and swaps leading/trailing whitespace so the character faces right.
61
+ * Half-block characters (▀ ▄) are horizontally symmetric so no char swap needed.
62
+ */
63
+ function flipLine(line) {
64
+ const stripped = line.replace(/\x1b\[[^m]*m/g, "");
65
+ const leadSpaces = stripped.match(/^( *)/)[1].length;
66
+ const trailSpaces = stripped.match(/( *)$/)[1].length;
67
+ // Parse pixel segments: each is one or two ANSI color codes followed by a block char.
68
+ // We strip any leading reset (\x1b[0m) from captured codes — it's an artifact from
69
+ // the original per-pixel reset pattern and will be re-added during reassembly.
70
+ const pixels = [];
71
+ const pixelRegex = /((?:\x1b\[[^m]*m)+)([\u2580\u2584])/g;
72
+ let match;
73
+ while ((match = pixelRegex.exec(line)) !== null) {
74
+ const codes = match[1].replace(/\x1b\[0m/g, "");
75
+ if (codes)
76
+ pixels.push({ codes, char: match[2] });
77
+ }
78
+ if (pixels.length === 0)
79
+ return line; // blank or space-only line
80
+ const reversed = [...pixels].reverse();
81
+ const newLead = " ".repeat(trailSpaces);
82
+ const newTrail = " ".repeat(leadSpaces);
83
+ let result = newLead;
84
+ for (const px of reversed) {
85
+ result += px.codes + px.char + "\x1b[0m";
86
+ }
87
+ result += newTrail;
88
+ return result;
89
+ }
90
+ /**
91
+ * Generate horizontally flipped art (facing right).
92
+ */
93
+ function generateFlippedArt(art) {
94
+ return art.map(flipLine);
95
+ }
96
+ /** Pre-computed right-facing art */
97
+ export const PHREN_ART_RIGHT = generateFlippedArt(PHREN_ART);
98
+ /** Random integer in [min, max] inclusive */
99
+ function randInt(min, max) {
100
+ return Math.floor(Math.random() * (max - min + 1)) + min;
101
+ }
102
+ /**
103
+ * Replace eye pixels on a single line with the blink color.
104
+ * Eye pixels are identified by their dark navy fg color (R<30, G<45, B<120).
105
+ * We replace the fg color code while preserving the bg code and character.
106
+ */
107
+ function applyBlinkToLine(line) {
108
+ // Match ANSI color sequences: each pixel is \e[38;2;R;G;Bm (optionally with \e[48;2;...m) then a block char
109
+ // We scan for fg codes that match the eye threshold and replace them with the blink color
110
+ return line.replace(/\x1b\[38;2;(\d+);(\d+);(\d+)m/g, (full, rStr, gStr, bStr) => {
111
+ const r = Number(rStr);
112
+ const g = Number(gStr);
113
+ const b = Number(bStr);
114
+ if (r < EYE_R_MAX && g < EYE_G_MAX && b < EYE_B_MAX) {
115
+ return `\x1b[38;2;${BLINK_COLOR}m`;
116
+ }
117
+ return full;
118
+ });
119
+ }
120
+ /**
121
+ * Replace the sparkle pixels on the sparkle row with the current sparkle character.
122
+ * The sparkle row has two cyan ▄ half-blocks. During sparkle animation we replace
123
+ * them with decorative Unicode characters from SPARKLE_CHARS.
124
+ */
125
+ function applySparkleToLine(line, frame, active) {
126
+ if (!active)
127
+ return line;
128
+ const sparkleChar = SPARKLE_CHARS[frame % SPARKLE_CHARS.length];
129
+ if (sparkleChar === " ") {
130
+ // Dim: replace the cyan-colored segments with spaces (they disappear)
131
+ return line.replace(/\x1b\[38;2;\d+;2\d\d;2\d\dm[\u2580\u2584]\x1b\[0m/g, " ");
132
+ }
133
+ // Replace the half-block characters with the sparkle unicode char, keeping the cyan color
134
+ return line.replace(/(\x1b\[38;2;\d+;2\d\d;2\d\dm)[\u2580\u2584](\x1b\[0m)/g, `$1${sparkleChar}$2`);
135
+ }
136
+ /**
137
+ * Apply lean (horizontal shift) to a line.
138
+ * Positive offset = shift right (prepend spaces, trim from end).
139
+ * Negative offset = shift left (trim from start, append spaces).
140
+ */
141
+ function applyLean(line, offset) {
142
+ if (offset === 0)
143
+ return line;
144
+ if (offset > 0) {
145
+ // Shift right: prepend spaces
146
+ return " ".repeat(offset) + line;
147
+ }
148
+ // Shift left: remove leading spaces (up to |offset|)
149
+ const trimCount = Math.min(-offset, line.match(/^( *)/)[1].length);
150
+ return line.slice(trimCount);
151
+ }
152
+ /**
153
+ * Create an animated phren character controller.
154
+ *
155
+ * @param options.facing - 'left' (default, original) or 'right' (flipped)
156
+ * @param options.size - art width; unused but reserved for future scaling (default 24)
157
+ */
158
+ export function createPhrenAnimator(options) {
159
+ const facing = options?.facing ?? "left";
160
+ const baseArt = facing === "right" ? PHREN_ART_RIGHT : PHREN_ART;
161
+ const state = {
162
+ bobUp: false,
163
+ isBlinking: false,
164
+ sparkleFrame: 0,
165
+ sparkleActive: false,
166
+ leanOffset: 0,
167
+ };
168
+ const timers = [];
169
+ function scheduleTimer(fn, ms) {
170
+ const t = setTimeout(fn, ms);
171
+ timers.push(t);
172
+ }
173
+ // ── Bob animation: toggles bobUp every ~500ms ──────────────────────────
174
+ function scheduleBob() {
175
+ scheduleTimer(() => {
176
+ state.bobUp = !state.bobUp;
177
+ scheduleBob();
178
+ }, 500);
179
+ }
180
+ // ── Blink animation: eyes close for 150ms, random 2-8s intervals ──────
181
+ function scheduleBlink() {
182
+ const interval = randInt(2000, 8000);
183
+ scheduleTimer(() => {
184
+ // Perform blink
185
+ state.isBlinking = true;
186
+ scheduleTimer(() => {
187
+ state.isBlinking = false;
188
+ // 30% chance of double-blink
189
+ if (Math.random() < 0.3) {
190
+ scheduleTimer(() => {
191
+ state.isBlinking = true;
192
+ scheduleTimer(() => {
193
+ state.isBlinking = false;
194
+ scheduleBlink();
195
+ }, 150);
196
+ }, 200);
197
+ }
198
+ else {
199
+ scheduleBlink();
200
+ }
201
+ }, 150);
202
+ }, interval);
203
+ }
204
+ // ── Sparkle animation: fast cycle during bursts, long pauses between ───
205
+ function scheduleSparkle() {
206
+ // Wait 1-5 seconds before next sparkle burst
207
+ const pause = randInt(1000, 5000);
208
+ scheduleTimer(() => {
209
+ state.sparkleActive = true;
210
+ state.sparkleFrame = 0;
211
+ sparkleStep(0);
212
+ }, pause);
213
+ }
214
+ function sparkleStep(step) {
215
+ if (step >= SPARKLE_CHARS.length) {
216
+ // Burst complete
217
+ state.sparkleActive = false;
218
+ scheduleSparkle();
219
+ return;
220
+ }
221
+ state.sparkleFrame = step;
222
+ scheduleTimer(() => {
223
+ sparkleStep(step + 1);
224
+ }, 200);
225
+ }
226
+ // ── Lean animation: shift 1 col left or right every 4-10s, hold 1-2s ──
227
+ function scheduleLean() {
228
+ const interval = randInt(4000, 10000);
229
+ scheduleTimer(() => {
230
+ const direction = Math.random() < 0.5 ? -1 : 1;
231
+ state.leanOffset = direction;
232
+ const holdTime = randInt(1000, 2000);
233
+ scheduleTimer(() => {
234
+ state.leanOffset = 0;
235
+ scheduleLean();
236
+ }, holdTime);
237
+ }, interval);
238
+ }
239
+ return {
240
+ getFrame() {
241
+ let lines = baseArt.map((line, i) => {
242
+ let result = line;
243
+ // Apply blink to the eye row
244
+ if (state.isBlinking && i === EYE_ROW) {
245
+ result = applyBlinkToLine(result);
246
+ }
247
+ // Apply sparkle to sparkle row
248
+ if (i === SPARKLE_ROW) {
249
+ result = applySparkleToLine(result, state.sparkleFrame, state.sparkleActive);
250
+ }
251
+ // Apply lean
252
+ result = applyLean(result, state.leanOffset);
253
+ return result;
254
+ });
255
+ // Apply bob: when bobUp, prepend a blank line (shift everything down visually)
256
+ if (state.bobUp) {
257
+ lines = ["", ...lines.slice(0, -1)];
258
+ }
259
+ return lines;
260
+ },
261
+ start() {
262
+ scheduleBob();
263
+ scheduleBlink();
264
+ scheduleSparkle();
265
+ scheduleLean();
266
+ },
267
+ stop() {
268
+ for (const t of timers) {
269
+ clearTimeout(t);
270
+ }
271
+ timers.length = 0;
272
+ },
273
+ };
274
+ }
275
+ // ── Startup frames (pre-baked, no timers) ────────────────────────────────────
276
+ /**
277
+ * Returns 4 pre-baked animation frames for shell startup display.
278
+ * No timers needed — the caller cycles through them manually.
279
+ *
280
+ * Frames: [neutral, bob-up, neutral, bob-down(sparkle)]
281
+ *
282
+ * @param facing - 'left' (default) or 'right'
283
+ */
284
+ export function getPhrenStartupFrames(facing) {
285
+ const art = facing === "right" ? PHREN_ART_RIGHT : PHREN_ART;
286
+ // Frame 0: neutral
287
+ const frame0 = [...art];
288
+ // Frame 1: bob up (prepend blank line, drop last line)
289
+ const frame1 = ["", ...art.slice(0, -1)];
290
+ // Frame 2: neutral (same as frame 0)
291
+ const frame2 = [...art];
292
+ // Frame 3: bob down with sparkle burst — shift down by removing first line, append blank
293
+ const frame3WithSparkle = art.map((line, i) => {
294
+ if (i === SPARKLE_ROW) {
295
+ return applySparkleToLine(line, 0, true); // ✦ sparkle
296
+ }
297
+ return line;
298
+ });
299
+ const frame3 = [...frame3WithSparkle.slice(1), ""];
300
+ return [frame0, frame1, frame2, frame3];
301
+ }
302
+ // ── Legacy exports (unchanged) ───────────────────────────────────────────────
36
303
  /** Single-line compact phren for inline use */
37
304
  export const PHREN_INLINE = `${PURPLE}◆${RESET}`;
38
305
  /** Phren spinner frames for search/sync operations — cycles through in purple */
@@ -1,6 +1,6 @@
1
1
  // Shared Phren result types, validation tags, and low-level helpers.
2
2
  /**
3
- * Minimal cross-domain starter set for entity/conflict detection.
3
+ * Minimal cross-domain starter set for fragment/conflict detection.
4
4
  *
5
5
  * Kept intentionally small: only terms that are genuinely universal across
6
6
  * disciplines (languages, infra primitives, version control). Framework-specific
@@ -9,8 +9,8 @@
9
9
  */
10
10
  export const UNIVERSAL_TECH_TERMS_RE = /\b(Python|Rust|Go|Java|TypeScript|JavaScript|Docker|Kubernetes|AWS|GCP|Azure|SQL|Git)\b/gi;
11
11
  /**
12
- * Additional entity patterns beyond CamelCase and acronyms.
13
- * Each pattern has a named group so callers can identify the entity type.
12
+ * Additional fragment patterns beyond CamelCase and acronyms.
13
+ * Each pattern has a named group so callers can identify the fragment type.
14
14
  */
15
15
  export const EXTRA_ENTITY_PATTERNS = [
16
16
  // Semantic version numbers: v1.2.3, 2.0.0-beta.1
@@ -91,7 +91,7 @@ export const FINDING_TAGS = FINDING_TYPES;
91
91
  /** Canonical set of known observation tags — derived from FINDING_TYPES */
92
92
  export const KNOWN_OBSERVATION_TAGS = new Set(FINDING_TYPES);
93
93
  /** Document types in the FTS index */
94
- export const DOC_TYPES = ["claude", "findings", "reference", "skills", "summary", "task", "changelog", "canonical", "memory-queue", "skill", "other"];
94
+ export const DOC_TYPES = ["claude", "findings", "reference", "skills", "summary", "task", "changelog", "canonical", "review-queue", "skill", "other"];
95
95
  // ── Cache eviction helper ────────────────────────────────────────────────────
96
96
  const CACHE_MAX = 1000;
97
97
  const CACHE_EVICT = 100;
@@ -433,7 +433,7 @@ export function computePhrenLiveStateToken(phrenPath) {
433
433
  for (const projectDir of projectDirs) {
434
434
  const project = path.basename(projectDir);
435
435
  parts.push(`project:${project}`);
436
- for (const file of ["CLAUDE.md", "summary.md", "FINDINGS.md", "tasks.md", "MEMORY_QUEUE.md", "CANONICAL_MEMORIES.md", "topic-config.json", "phren.project.yaml"]) {
436
+ for (const file of ["CLAUDE.md", "summary.md", "FINDINGS.md", "tasks.md", "review.md", "truths.md", "topic-config.json", "phren.project.yaml"]) {
437
437
  pushFileToken(parts, path.join(projectDir, file));
438
438
  }
439
439
  pushDirTokens(parts, path.join(projectDir, "reference"));
@@ -209,7 +209,7 @@ function buildProjectCard(dir) {
209
209
  .split("\n")
210
210
  .map((line) => line.trim())
211
211
  .find((line) => line && !line.startsWith("#")) || "";
212
- const docs = ["CLAUDE.md", "FINDINGS.md", "summary.md", "MEMORY_QUEUE.md"]
212
+ const docs = ["CLAUDE.md", "FINDINGS.md", "summary.md", "review.md"]
213
213
  .filter((file) => fs.existsSync(path.join(dir, file)));
214
214
  const taskFile = TASK_FILE_ALIASES.find((file) => fs.existsSync(path.join(dir, file)));
215
215
  if (taskFile)