{"version":3,"file":"pai-marker-B20KqhA8.mjs","names":[],"sources":["../src/registry/pai-marker.ts"],"sourcesContent":["/**\n * PAI.md marker file management.\n *\n * Each registered project gets a `Notes/PAI.md` file with a YAML frontmatter\n * `pai:` block that PAI manages.  The rest of the file (body content, other\n * frontmatter keys) is user-owned and never modified by PAI.\n *\n * YAML parsing/updating is done with simple regex — no external dependency.\n */\n\nimport {\n  existsSync,\n  readFileSync,\n  writeFileSync,\n  mkdirSync,\n  readdirSync,\n  statSync,\n} from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ---------------------------------------------------------------------------\n// Container directories\n// ---------------------------------------------------------------------------\n\n/**\n * Directories that hold projects but are not themselves projects.\n *\n * Running a single Claude session from ~/Desktop is enough for the scanner to\n * register it as a project, after which every scan recreates a `Notes/PAI.md`\n * inside it. The user gets a Notes folder on their Desktop that reappears\n * however many times they delete it, containing a marker file that is\n * otherwise empty.\n *\n * The session is still worth tracking — it just belongs in the central notes\n * directory (~/.claude/projects/<encoded>/Notes) rather than as a folder\n * dropped into the user's Desktop.\n */\nconst CONTAINER_DIRS = [\n  \"Desktop\",\n  \"Documents\",\n  \"Downloads\",\n  \"Movies\",\n  \"Music\",\n  \"Pictures\",\n  \"Public\",\n  \"Library\",\n  \"Applications\",\n];\n\n/**\n * True when a path is a place projects live rather than a project itself:\n * the home directory, a standard user container, or a system temp root.\n *\n * Only ever used to decline *creating* a marker. An existing marker is left\n * alone — the user may have put one there deliberately.\n */\nexport function isContainerDirectory(projectRoot: string): boolean {\n  // Strip trailing slashes, but never strip the root itself down to \"\".\n  const strip = (s: string): string => s.replace(/(?!^)\\/+$/, \"\");\n\n  const p = strip(resolve(projectRoot));\n  const home = strip(resolve(homedir()));\n\n  if (p === home || p === \"/\") return true;\n  if (p === \"/tmp\" || p === \"/private/tmp\" || p === \"/var/tmp\") return true;\n\n  for (const dir of CONTAINER_DIRS) {\n    if (p === join(home, dir)) return true;\n  }\n\n  return false;\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PaiMarker {\n  /** Absolute path to the PAI.md file */\n  path: string;\n  /** The `slug` value from the `pai:` frontmatter block */\n  slug: string;\n  /** Absolute path to the project root (parent of Notes/) */\n  projectRoot: string;\n}\n\n// ---------------------------------------------------------------------------\n// Template content (mirrors templates/pai-project.template.md)\n// ---------------------------------------------------------------------------\n\nconst TEMPLATE = `---\npai:\n  slug: \"\\${SLUG}\"\n  registered: \"\\${DATE}\"\n  last_indexed: null\n  status: active\n---\n\n# \\${DISPLAY_NAME}\n\n<!-- Everything below the YAML frontmatter is yours — PAI never modifies content here. -->\n<!-- Use this file for project notes, decisions, preferences, or anything you want. -->\n<!-- PAI only reads and updates the \\`pai:\\` block in the frontmatter above. -->\n`;\n\nfunction isoDate(): string {\n  return new Date().toISOString().slice(0, 10);\n}\n\nfunction renderTemplate(slug: string, displayName: string): string {\n  return TEMPLATE.replace(/\\$\\{SLUG\\}/g, slug)\n    .replace(/\\$\\{DATE\\}/g, isoDate())\n    .replace(/\\$\\{DISPLAY_NAME\\}/g, displayName);\n}\n\n// ---------------------------------------------------------------------------\n// YAML frontmatter helpers (regex-based, no external dependency)\n// ---------------------------------------------------------------------------\n\n/**\n * Split a markdown file with YAML frontmatter into its parts.\n *\n * Returns { frontmatter, body } where:\n *   frontmatter  — content between the opening and closing `---` delimiters\n *   body         — everything after the closing `---` line\n *\n * Returns null if the file does not begin with a `---` frontmatter block.\n */\nfunction parseFrontmatter(\n  content: string\n): { frontmatter: string; body: string } | null {\n  if (!content.startsWith(\"---\")) return null;\n\n  // Skip past the opening \"---\" and its line ending\n  const afterOpen = content.slice(3);\n  const eolMatch = afterOpen.match(/^\\r?\\n/);\n  if (!eolMatch) return null;\n\n  const rest = afterOpen.slice(eolMatch[0].length);\n\n  // Find closing \"---\" on its own line\n  const closeMatch = rest.match(/^([\\s\\S]*?)\\n---[ \\t]*(\\r?\\n|$)/m);\n  if (!closeMatch) return null;\n\n  const frontmatter = closeMatch[1];\n  const body = rest.slice(closeMatch[0].length);\n\n  return { frontmatter, body };\n}\n\n/**\n * Extract a simple scalar YAML value from a block of YAML text.\n *\n *   extractYamlValue('  slug: \"my-proj\"', \"slug\")  →  \"my-proj\"\n *   extractYamlValue('  slug: my-proj', \"slug\")     →  \"my-proj\"\n *   extractYamlValue('  last_indexed: null', \"last_indexed\")  →  \"null\"\n */\nfunction extractYamlValue(yamlBlock: string, key: string): string | null {\n  const re = new RegExp(\n    `^[ \\\\t]*${key}[ \\\\t]*:[ \\\\t]*\"?([^\"\\\\n\\\\r]*?)\"?[ \\\\t]*$`,\n    \"m\"\n  );\n  const match = yamlBlock.match(re);\n  if (!match) return null;\n  return match[1].trim() || null;\n}\n\n/**\n * Replace the `pai:` mapping block inside a frontmatter string with\n * `newPaiBlock`.  If no `pai:` block is found, appends it at the end.\n *\n * The regex captures the `pai:` key and all immediately-following indented\n * lines (the mapping values), then replaces the whole group.\n *\n * Edge case: the last indented line may not have a trailing newline when it\n * is the final line of the frontmatter string.  We handle this by matching\n * lines that end with \\n OR with end-of-string.\n */\nfunction replacePaiBlock(frontmatter: string, newPaiBlock: string): string {\n  // Normalise: ensure the frontmatter string ends with \\n so the regex\n  // always finds a clean boundary after the last indented line.\n  const fm = frontmatter.endsWith(\"\\n\") ? frontmatter : frontmatter + \"\\n\";\n\n  // Match \"pai:\\n\" followed by any number of indented lines (each ending \\n).\n  const paiRe = /^pai:[ \\t]*\\r?\\n(?:[ \\t]+[^\\r\\n]*\\r?\\n)*/m;\n  if (paiRe.test(fm)) {\n    // Replace and strip the extra trailing \\n we may have added.\n    const replaced = fm.replace(paiRe, newPaiBlock);\n    return frontmatter.endsWith(\"\\n\") ? replaced : replaced.replace(/\\n$/, \"\");\n  }\n  // pai: key not found — append it\n  return fm + newPaiBlock;\n}\n\n/**\n * Build the canonical `pai:` YAML block (with a trailing newline).\n */\nfunction buildPaiBlock(\n  slug: string,\n  registered: string,\n  lastIndexed: string | null,\n  status: string\n): string {\n  const lastIndexedStr =\n    lastIndexed === null ? \"null\" : `\"${lastIndexed}\"`;\n  return (\n    `pai:\\n` +\n    `  slug: \"${slug}\"\\n` +\n    `  registered: \"${registered}\"\\n` +\n    `  last_indexed: ${lastIndexedStr}\\n` +\n    `  status: ${status}\\n`\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Create or update `<projectRoot>/Notes/PAI.md`.\n *\n * - File absent: creates `Notes/` if needed, writes from template.\n * - File present: updates only the `pai:` frontmatter block; body and all\n *   other frontmatter keys are preserved verbatim.\n *\n * @param projectRoot  Absolute path to the project root directory.\n * @param slug         PAI slug for this project.\n * @param displayName  Human-readable name (defaults to slug if omitted).\n */\nexport function ensurePaiMarker(\n  projectRoot: string,\n  slug: string,\n  displayName?: string\n): void {\n  const notesDir = join(projectRoot, \"Notes\");\n  const markerPath = join(notesDir, \"PAI.md\");\n  const name = displayName ?? slug;\n\n  // --- File does not exist — create from template ---\n  if (!existsSync(markerPath)) {\n    // Never create a Notes/ folder inside a container directory. Doing so is\n    // what puts a Notes folder on the user's Desktop that comes back every\n    // time it is deleted.\n    if (isContainerDirectory(projectRoot)) return;\n\n    mkdirSync(notesDir, { recursive: true });\n    writeFileSync(markerPath, renderTemplate(slug, name), \"utf8\");\n    return;\n  }\n\n  // --- File exists — update only the `pai:` block ---\n  const raw = readFileSync(markerPath, \"utf8\");\n  const parsed = parseFrontmatter(raw);\n\n  if (!parsed) {\n    // No YAML frontmatter — prepend a fresh one, treat the whole file as body.\n    const paiBlock = buildPaiBlock(slug, isoDate(), null, \"active\");\n    const newContent = `---\\n${paiBlock}---\\n\\n${raw}`;\n    writeFileSync(markerPath, newContent, \"utf8\");\n    return;\n  }\n\n  const { frontmatter, body } = parsed;\n\n  // Preserve existing `registered` date so we don't reset it on re-scan.\n  const existingRegistered =\n    extractYamlValue(frontmatter, \"registered\") ?? isoDate();\n\n  // Preserve existing `last_indexed` value (may be \"null\" string or a date).\n  const rawLastIndexed = extractYamlValue(frontmatter, \"last_indexed\");\n  const lastIndexed =\n    rawLastIndexed === null || rawLastIndexed === \"null\"\n      ? null\n      : rawLastIndexed;\n\n  // Preserve existing `status`.\n  const existingStatus = extractYamlValue(frontmatter, \"status\") ?? \"active\";\n\n  const newPaiBlock = buildPaiBlock(\n    slug,\n    existingRegistered,\n    lastIndexed,\n    existingStatus\n  );\n\n  const newFrontmatter = replacePaiBlock(frontmatter, newPaiBlock);\n\n  // Ensure the frontmatter block ends with exactly one newline before the\n  // closing --- delimiter.\n  const fmWithNewline = newFrontmatter.endsWith(\"\\n\")\n    ? newFrontmatter\n    : newFrontmatter + \"\\n\";\n\n  // Reconstruct the full file.  Preserve whatever separator the body has.\n  const newContent = `---\\n${fmWithNewline}---\\n${body}`;\n  writeFileSync(markerPath, newContent, \"utf8\");\n}\n\n/**\n * Read PAI marker data from `<projectRoot>/Notes/PAI.md`.\n * Returns null if the file does not exist or contains no `pai:` block.\n */\nexport function readPaiMarker(\n  projectRoot: string\n): { slug: string; registered: string; status: string } | null {\n  const markerPath = join(projectRoot, \"Notes\", \"PAI.md\");\n  if (!existsSync(markerPath)) return null;\n\n  const raw = readFileSync(markerPath, \"utf8\");\n  const parsed = parseFrontmatter(raw);\n  if (!parsed) return null;\n\n  const slug = extractYamlValue(parsed.frontmatter, \"slug\");\n  if (!slug) return null;\n\n  const registered =\n    extractYamlValue(parsed.frontmatter, \"registered\") ?? \"\";\n  const status =\n    extractYamlValue(parsed.frontmatter, \"status\") ?? \"active\";\n\n  return { slug, registered, status };\n}\n\n/**\n * Scan a list of parent directories for `<child>/Notes/PAI.md` marker files.\n * Each directory in `searchDirs` is scanned one level deep — its immediate\n * child directories are checked for a `Notes/PAI.md` file.\n *\n * Returns an array of PaiMarker objects for every valid marker found.\n * Invalid or malformed markers are silently skipped.\n *\n * @param searchDirs  Absolute paths to parent directories.\n */\nexport function discoverPaiMarkers(searchDirs: string[]): PaiMarker[] {\n  const results: PaiMarker[] = [];\n\n  for (const dir of searchDirs) {\n    if (!existsSync(dir)) continue;\n\n    let children: string[];\n    try {\n      children = readdirSync(dir);\n    } catch {\n      continue;\n    }\n\n    for (const child of children) {\n      if (child.startsWith(\".\")) continue;\n      const childPath = join(dir, child);\n      try {\n        if (!statSync(childPath).isDirectory()) continue;\n      } catch {\n        continue;\n      }\n\n      const markerData = readPaiMarker(childPath);\n      if (!markerData) continue;\n\n      results.push({\n        path: join(childPath, \"Notes\", \"PAI.md\"),\n        slug: markerData.slug,\n        projectRoot: childPath,\n      });\n    }\n  }\n\n  return results;\n}\n"],"mappings":"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"}