safeword 0.44.0 → 0.45.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.
Files changed (48) hide show
  1. package/dist/{check-XSDIO2P6.js → check-C3IEG3XA.js} +38 -7
  2. package/dist/check-C3IEG3XA.js.map +1 -0
  3. package/dist/{chunk-VZ2E2QRM.js → chunk-46XXWC64.js} +8 -4
  4. package/dist/chunk-46XXWC64.js.map +1 -0
  5. package/dist/{chunk-XI4SIM76.js → chunk-I7ONBYQU.js} +2 -2
  6. package/dist/{chunk-QNLC7KYH.js → chunk-K5EJJIPT.js} +78 -5
  7. package/dist/chunk-K5EJJIPT.js.map +1 -0
  8. package/dist/{chunk-HDMKYYSJ.js → chunk-KWD4OQL4.js} +3 -3
  9. package/dist/chunk-NHXVS5FL.js +9 -0
  10. package/dist/chunk-NHXVS5FL.js.map +1 -0
  11. package/dist/{chunk-FL2WCXYR.js → chunk-ZLEHZR4V.js} +114 -83
  12. package/dist/chunk-ZLEHZR4V.js.map +1 -0
  13. package/dist/cli.js +10 -10
  14. package/dist/{codify-G5JQ5UAC.js → codify-OATQEQON.js} +2 -2
  15. package/dist/{diff-WSMYBDOA.js → diff-EQIZFEKE.js} +3 -3
  16. package/dist/{reset-4QRBI43N.js → reset-XPAO6S2X.js} +3 -3
  17. package/dist/{setup-QRWRPPHY.js → setup-TYIQKWJH.js} +5 -5
  18. package/dist/{sync-config-BSMOY4NM.js → sync-config-X5PHVGEY.js} +3 -3
  19. package/dist/{sync-learnings-KNT3F6GI.js → sync-learnings-TS3UJAWI.js} +2 -2
  20. package/dist/{sync-tickets-RCRSYBQ5.js → sync-tickets-AGSPGGQN.js} +4 -3
  21. package/dist/{sync-tickets-RCRSYBQ5.js.map → sync-tickets-AGSPGGQN.js.map} +1 -1
  22. package/dist/{ticket-new-P5BT7OIE.js → ticket-new-DASC7THG.js} +6 -3
  23. package/dist/ticket-new-DASC7THG.js.map +1 -0
  24. package/dist/{upgrade-7LGMS64Y.js → upgrade-Q2JUR6VU.js} +8 -5
  25. package/dist/upgrade-Q2JUR6VU.js.map +1 -0
  26. package/package.json +15 -15
  27. package/templates/SAFEWORD.md +2 -0
  28. package/templates/hooks/lib/active-ticket.ts +6 -0
  29. package/templates/hooks/lib/replan-relevance.ts +51 -0
  30. package/templates/hooks/lib/replan.ts +73 -8
  31. package/templates/hooks/prompt-questions.ts +8 -0
  32. package/templates/hooks/session-compact-context.ts +3 -4
  33. package/templates/hooks/stop-quality.ts +3 -1
  34. package/templates/skills/explain/SKILL.md +99 -0
  35. package/dist/check-XSDIO2P6.js.map +0 -1
  36. package/dist/chunk-FL2WCXYR.js.map +0 -1
  37. package/dist/chunk-QNLC7KYH.js.map +0 -1
  38. package/dist/chunk-VZ2E2QRM.js.map +0 -1
  39. package/dist/ticket-new-P5BT7OIE.js.map +0 -1
  40. package/dist/upgrade-7LGMS64Y.js.map +0 -1
  41. /package/dist/{chunk-XI4SIM76.js.map → chunk-I7ONBYQU.js.map} +0 -0
  42. /package/dist/{chunk-HDMKYYSJ.js.map → chunk-KWD4OQL4.js.map} +0 -0
  43. /package/dist/{codify-G5JQ5UAC.js.map → codify-OATQEQON.js.map} +0 -0
  44. /package/dist/{diff-WSMYBDOA.js.map → diff-EQIZFEKE.js.map} +0 -0
  45. /package/dist/{reset-4QRBI43N.js.map → reset-XPAO6S2X.js.map} +0 -0
  46. /package/dist/{setup-QRWRPPHY.js.map → setup-TYIQKWJH.js.map} +0 -0
  47. /package/dist/{sync-config-BSMOY4NM.js.map → sync-config-X5PHVGEY.js.map} +0 -0
  48. /package/dist/{sync-learnings-KNT3F6GI.js.map → sync-learnings-TS3UJAWI.js.map} +0 -0
@@ -1,6 +1,12 @@
1
1
  import {
2
+ findDanglingDependencies,
3
+ findTicketsInCycles,
4
+ readTickets,
2
5
  syncTickets
3
- } from "./chunk-QNLC7KYH.js";
6
+ } from "./chunk-K5EJJIPT.js";
7
+ import {
8
+ formatTicketReference
9
+ } from "./chunk-NHXVS5FL.js";
4
10
  import {
5
11
  buildCoverageReport,
6
12
  computeSkipMask,
@@ -16,7 +22,7 @@ import {
16
22
  readConfiguredPath,
17
23
  reconcile,
18
24
  resolveConfiguredPath
19
- } from "./chunk-FL2WCXYR.js";
25
+ } from "./chunk-ZLEHZR4V.js";
20
26
  import "./chunk-LODQOJEK.js";
21
27
  import {
22
28
  VERSION
@@ -32,7 +38,7 @@ import {
32
38
  listItem,
33
39
  success,
34
40
  warn
35
- } from "./chunk-VZ2E2QRM.js";
41
+ } from "./chunk-46XXWC64.js";
36
42
 
37
43
  // src/commands/check.ts
38
44
  import { readdirSync as readdirSync2 } from "fs";
@@ -482,18 +488,42 @@ function isInProgress(ticketContent) {
482
488
  return false;
483
489
  }
484
490
  function formatCoverageReport(ticketId, report) {
491
+ const dashIndex = ticketId.indexOf("-");
492
+ const ticketLabel = dashIndex === -1 ? ticketId : formatTicketReference(ticketId.slice(0, dashIndex), ticketId.slice(dashIndex + 1));
485
493
  return [
486
494
  ...report.uncovered.map(
487
- (acId) => `${ticketId}: acceptance criterion ${acId} has no scenario (uncovered)`
495
+ (acId) => `${ticketLabel}: acceptance criterion ${acId} has no scenario (uncovered)`
488
496
  ),
489
497
  ...report.stale.map(
490
- (reference) => `${ticketId}: scenario ref ${reference} matches no AC under its JTBD (stale ref)`
498
+ (reference) => `${ticketLabel}: scenario ref ${reference} matches no AC under its JTBD (stale ref)`
491
499
  ),
492
500
  ...report.orphan.map(
493
- (reference) => `${ticketId}: scenario ref ${reference} names no JTBD in spec.md (orphan)`
501
+ (reference) => `${ticketLabel}: scenario ref ${reference} names no JTBD in spec.md (orphan)`
494
502
  )
495
503
  ];
496
504
  }
505
+ function findRelationAdvisories(cwd) {
506
+ const ticketsDirectory = nodePath4.join(cwd, ...TICKETS_SUBPATH);
507
+ let entries;
508
+ try {
509
+ const { active, completed } = readTickets(ticketsDirectory);
510
+ entries = [...active, ...completed];
511
+ } catch {
512
+ return [];
513
+ }
514
+ const nodes = entries.map((entry) => ({ id: entry.id, dependsOn: entry.dependsOn }));
515
+ const labelById = new Map(entries.map((entry) => [entry.id, entry.title]));
516
+ const refOf = (id) => {
517
+ const title = labelById.get(id);
518
+ return title === void 0 ? id : formatTicketReference(id, title);
519
+ };
520
+ const dangling = findDanglingDependencies(nodes).map(
521
+ ({ from, missing }) => `${refOf(from)}: depends_on ${missing} \u2014 no such ticket (dangling ref)`
522
+ );
523
+ const cyclic = findTicketsInCycles(nodes);
524
+ const cycle = cyclic.length > 0 ? [`dependency cycle among: ${cyclic.map((id) => refOf(id)).join(", ")} (break the loop)`] : [];
525
+ return [...dangling, ...cycle];
526
+ }
497
527
  function findMissingPatches(cwd, actions) {
498
528
  const issues = [];
499
529
  for (const action of actions) {
@@ -572,6 +602,7 @@ async function checkHealth(cwd) {
572
602
  ...findPersonaAdvisories(cwd),
573
603
  ...findGlossaryAdvisories(cwd),
574
604
  ...findCoverageAdvisories(cwd),
605
+ ...findRelationAdvisories(cwd),
575
606
  ...findArchitectureAdvisories(cwd)
576
607
  ],
577
608
  missingPackages: result.packagesToInstall,
@@ -677,4 +708,4 @@ async function check(options) {
677
708
  export {
678
709
  check
679
710
  };
680
- //# sourceMappingURL=check-XSDIO2P6.js.map
711
+ //# sourceMappingURL=check-C3IEG3XA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/commands/check.ts","../src/utils/architecture-records.ts","../src/utils/glossary.ts","../src/utils/validation.ts","../src/utils/personas.ts"],"sourcesContent":["/**\n * Check command - Verify project health and configuration\n *\n * Uses reconcile() with dryRun to detect missing files and configuration issues.\n */\n\nimport { readdirSync } from 'node:fs';\nimport nodePath from 'node:path';\n\nimport { getMissingPacks } from '../packs/registry.js';\nimport { reconcile } from '../reconcile.js';\nimport { SAFEWORD_SCHEMA } from '../schema.js';\nimport { readTickets, syncTickets } from '../ticket-sync/index.js';\nimport { listArchitectureRecords } from '../utils/architecture-records.js';\nimport { readConfiguredPath, resolveConfiguredPath } from '../utils/configured-paths.js';\nimport { createProjectContext } from '../utils/context.js';\nimport { exists, readFileSafe } from '../utils/fs.js';\nimport { GLOSSARY_FILE_SUBPATH, parseGlossary, validateGlossary } from '../utils/glossary.js';\nimport { header, info, keyValue, listItem, success, warn } from '../utils/output.js';\nimport { parsePersonas, PERSONAS_FILE_SUBPATH, validatePersonas } from '../utils/personas.js';\nimport { buildCoverageReport, type CoverageReport } from '../utils/scenario-coverage.js';\nimport { formatTicketReference } from '../utils/ticket-reference.js';\nimport { findDanglingDependencies, findTicketsInCycles } from '../utils/ticket-relations.js';\nimport { isNewerVersion } from '../utils/version.js';\nimport { VERSION } from '../version.js';\n\ninterface CheckOptions {\n offline?: boolean;\n}\n\n/**\n * Check for missing files from write actions\n * @param cwd\n * @param actions\n */\nfunction findMissingFiles(cwd: string, actions: { type: string; path: string }[]): string[] {\n const issues: string[] = [];\n for (const action of actions) {\n if (action.type === 'write' && !exists(nodePath.join(cwd, action.path))) {\n issues.push(`Missing: ${action.path}`);\n }\n }\n return issues;\n}\n\n// The persona/glossary find*Issues + find*Advisories pairs below (and the\n// validate*Reference / lookup* pairs in personas.ts / glossary.ts) are\n// intentionally parallel, NOT a missed extraction: the cores diverge (persona\n// matches code/name, glossary matches name/alias; different parse+validate\n// fns and messages), and where they don't, deduping two call sites into a\n// multi-param helper would cost clarity. Assessed in ticket XEP59N — leave as is.\n\n/**\n * Validate personas.md when present, routing through any configured\n * `paths.personas` override. Returns one issue string per persona\n * validation error, formatted as `personas.md:LINE: MESSAGE`.\n *\n * Two failure modes:\n * - Default location absent → no issue (scaffold is optional until JTBDs\n * reference personas).\n * - Configured override set but file absent → loud failure (user opted\n * in; typo would otherwise silently strand persona references). Ticket\n * K7N2QM.\n */\nfunction findPersonaIssues(cwd: string): string[] {\n const override = readConfiguredPath(cwd, 'personas');\n const filePath = resolveConfiguredPath(cwd, 'personas', nodePath.join(...PERSONAS_FILE_SUBPATH));\n const content = readFileSafe(filePath);\n\n if (content === undefined) {\n if (override !== undefined) {\n return [`personas-path: ${override}: file not found`];\n }\n return [];\n }\n\n const errors = validatePersonas(parsePersonas(content));\n return errors.map(error => `personas.md:${error.line}: ${error.message}`);\n}\n\n/**\n * Surface non-blocking diagnostics about persona path configuration.\n * Currently: when `paths.personas` is set AND the default-location file\n * `.safeword-project/personas.md` still exists, emit an advisory naming\n * the orphaned file. Safeword reads from the override; the legacy file\n * is dead weight and may confuse readers who think they're editing the\n * live file. Zero-exit — non-destructive (data-loss principle from\n * ticket K7N2QM); user owns cleanup.\n */\nfunction findPersonaAdvisories(cwd: string): string[] {\n const override = readConfiguredPath(cwd, 'personas');\n if (override === undefined) return [];\n const defaultPath = nodePath.join(cwd, ...PERSONAS_FILE_SUBPATH);\n if (!exists(defaultPath)) return [];\n return [\n `.safeword-project/personas.md exists but paths.personas points to ${override} — legacy file is orphaned. Consider removing.`,\n ];\n}\n\n/**\n * Validate glossary.md when present, routing through any configured\n * `paths.glossary` override. Returns one issue string per glossary\n * validation error, formatted as `glossary.md:LINE: MESSAGE`. Same two\n * failure modes as {@link findPersonaIssues} — absent default is silent\n * (scaffold is optional), configured-but-missing fails loudly (ticket\n * YR6C49, mirrors K7N2QM).\n */\nfunction findGlossaryIssues(cwd: string): string[] {\n const override = readConfiguredPath(cwd, 'glossary');\n const filePath = resolveConfiguredPath(cwd, 'glossary', nodePath.join(...GLOSSARY_FILE_SUBPATH));\n const content = readFileSafe(filePath);\n\n if (content === undefined) {\n if (override !== undefined) {\n return [`glossary-path: ${override}: file not found`];\n }\n return [];\n }\n\n const errors = validateGlossary(parseGlossary(content));\n return errors.map(error => `glossary.md:${error.line}: ${error.message}`);\n}\n\n/**\n * Surface non-blocking diagnostics about glossary path configuration.\n * When `paths.glossary` is set AND the default-location file still exists,\n * emit a zero-exit advisory naming the orphaned file (mirrors\n * {@link findPersonaAdvisories}; data-loss principle from K7N2QM).\n */\nfunction findGlossaryAdvisories(cwd: string): string[] {\n const override = readConfiguredPath(cwd, 'glossary');\n if (override === undefined) return [];\n const defaultPath = nodePath.join(cwd, ...GLOSSARY_FILE_SUBPATH);\n if (!exists(defaultPath)) return [];\n return [\n `.safeword-project/glossary.md exists but paths.glossary points to ${override} — legacy file is orphaned. Consider removing.`,\n ];\n}\n\nconst TICKETS_SUBPATH = ['.safeword-project', 'tickets'];\n\n/** Ticket folder names under the tickets root (excluding `completed/`), or\n * empty when the root is missing/unreadable. */\nfunction listTicketIds(ticketsRoot: string): string[] {\n try {\n return readdirSync(ticketsRoot, { withFileTypes: true })\n .filter(entry => entry.isDirectory() && entry.name !== 'completed')\n .map(entry => entry.name);\n } catch {\n return [];\n }\n}\n\nconst ARCHITECTURE_DEFAULT_SUBPATH = nodePath.join('.safeword-project', 'architecture.md');\n\n/**\n * Surface architecture-claim mismatches as non-blocking advisories (ticket\n * K4BWTQ). Structural only — no prose extraction (YR6C49 ruling): when an\n * in-progress ticket's impl-plan.md Arch alignment section carries content\n * (not `skip:`) but the resolved `paths.architecture` location does not\n * exist, the claim cannot be honoring anything recorded. Zero-exit.\n */\nfunction findArchitectureAdvisories(cwd: string): string[] {\n const ticketsRoot = nodePath.join(cwd, ...TICKETS_SUBPATH);\n const ticketIds = listTicketIds(ticketsRoot);\n\n const resolved = resolveConfiguredPath(cwd, 'architecture', ARCHITECTURE_DEFAULT_SUBPATH);\n if (listArchitectureRecords(resolved).kind !== 'absent') return [];\n\n return ticketIds.flatMap(ticketId => {\n const ticketDirectory = nodePath.join(ticketsRoot, ticketId);\n const ticketContent = readFileSafe(nodePath.join(ticketDirectory, 'ticket.md'));\n if (ticketContent === undefined || !isInProgress(ticketContent)) return [];\n const implPlan = readFileSafe(nodePath.join(ticketDirectory, 'impl-plan.md'));\n if (implPlan === undefined) return [];\n if (!archAlignmentHasContent(implPlan)) return [];\n return [\n `${ticketId}: impl-plan.md Arch alignment claims alignment, but no architecture record exists at ${resolved} — record the decision or mark the section skip:`,\n ];\n });\n}\n\n/** Whether the impl plan's `## Arch alignment` section carries real content\n * (non-empty, not a `skip:` annotation). */\nfunction archAlignmentHasContent(implPlanContent: string): boolean {\n let inSection = false;\n const body: string[] = [];\n for (const raw of implPlanContent.split('\\n')) {\n const line = raw.trim();\n if (line.startsWith('## ')) {\n inSection = line.slice(3).trim().toLowerCase() === 'arch alignment';\n continue;\n }\n if (inSection && line !== '') body.push(line);\n }\n if (body.length === 0) return false;\n return !(body.length === 1 && (body[0] ?? '').toLowerCase().startsWith('skip:'));\n}\n\n/**\n * Surface scenario-lineage coverage gaps as non-blocking advisories (ticket\n * XT1FFM). Scoped to `status: in_progress` tickets that carry a spec.md —\n * which excludes done predecessors whose pre-scheme scenarios are the\n * out-of-scope migration case (epic DZ2NM5/D5), and keeps the report focused\n * on the work the developer is actually building. Each in-progress ticket's\n * (spec.md, test-definitions.md) pair is cross-referenced into uncovered ACs,\n * stale AC refs, and orphan scenarios. Zero-exit — advisory, never a gate.\n */\nfunction findCoverageAdvisories(cwd: string): string[] {\n const ticketsRoot = nodePath.join(cwd, ...TICKETS_SUBPATH);\n return listTicketIds(ticketsRoot).flatMap(ticketId =>\n coverageAdvisoriesForTicket(ticketsRoot, ticketId),\n );\n}\n\n/** Build coverage advisories for one ticket, or none if it is not an\n * in-progress, spec-bearing ticket. */\nfunction coverageAdvisoriesForTicket(ticketsRoot: string, ticketId: string): string[] {\n const ticketDirectory = nodePath.join(ticketsRoot, ticketId);\n const ticketContent = readFileSafe(nodePath.join(ticketDirectory, 'ticket.md'));\n if (ticketContent === undefined || !isInProgress(ticketContent)) return [];\n\n const specContent = readFileSafe(nodePath.join(ticketDirectory, 'spec.md'));\n if (specContent === undefined) return [];\n\n const testDefinitionsContent = readFileSafe(\n nodePath.join(ticketDirectory, 'test-definitions.md'),\n );\n return formatCoverageReport(ticketId, buildCoverageReport(specContent, testDefinitionsContent));\n}\n\n/** Whether a ticket.md's frontmatter declares `status: in_progress`. */\nfunction isInProgress(ticketContent: string): boolean {\n const lines = ticketContent.split('\\n');\n if (lines[0]?.trim() !== '---') return false;\n for (let index = 1; index < lines.length; index += 1) {\n const line = (lines[index] ?? '').trim();\n if (line === '---') return false;\n if (line === 'status: in_progress') return true;\n }\n return false;\n}\n\n/** Render a coverage report into one advisory string per finding. */\nfunction formatCoverageReport(ticketId: string, report: CoverageReport): string[] {\n const dashIndex = ticketId.indexOf('-');\n const ticketLabel =\n dashIndex === -1\n ? ticketId\n : formatTicketReference(ticketId.slice(0, dashIndex), ticketId.slice(dashIndex + 1));\n return [\n ...report.uncovered.map(\n acId => `${ticketLabel}: acceptance criterion ${acId} has no scenario (uncovered)`,\n ),\n ...report.stale.map(\n reference =>\n `${ticketLabel}: scenario ref ${reference} matches no AC under its JTBD (stale ref)`,\n ),\n ...report.orphan.map(\n reference => `${ticketLabel}: scenario ref ${reference} names no JTBD in spec.md (orphan)`,\n ),\n ];\n}\n\n/**\n * Surface structured-relation problems as non-blocking advisories (ticket\n * AKZJXC): a `depends_on` pointing at a ticket absent from the corpus (dangling\n * ref), and dependency cycles (A→B→A). Warn-only — a target may live on another\n * branch or in completed/, and a cycle is a planning smell, not a config fault.\n * Reads the full corpus (active + completed) so cross-status edges resolve.\n * Zero-exit.\n */\nfunction findRelationAdvisories(cwd: string): string[] {\n const ticketsDirectory = nodePath.join(cwd, ...TICKETS_SUBPATH);\n let entries;\n try {\n const { active, completed } = readTickets(ticketsDirectory);\n entries = [...active, ...completed];\n } catch {\n return [];\n }\n\n const nodes = entries.map(entry => ({ id: entry.id, dependsOn: entry.dependsOn }));\n const labelById = new Map(entries.map(entry => [entry.id, entry.title]));\n const refOf = (id: string): string => {\n const title = labelById.get(id);\n return title === undefined ? id : formatTicketReference(id, title);\n };\n\n const dangling = findDanglingDependencies(nodes).map(\n ({ from, missing }) => `${refOf(from)}: depends_on ${missing} — no such ticket (dangling ref)`,\n );\n const cyclic = findTicketsInCycles(nodes);\n const cycle =\n cyclic.length > 0\n ? [`dependency cycle among: ${cyclic.map(id => refOf(id)).join(', ')} (break the loop)`]\n : [];\n return [...dangling, ...cycle];\n}\n\n/**\n * Check for missing text patch markers\n * @param cwd\n * @param actions\n */\nfunction findMissingPatches(\n cwd: string,\n actions: { type: string; path: string; definition?: { marker: string } }[],\n): string[] {\n const issues: string[] = [];\n for (const action of actions) {\n if (action.type !== 'text-patch') continue;\n\n const fullPath = nodePath.join(cwd, action.path);\n if (exists(fullPath)) {\n const content = readFileSafe(fullPath) ?? '';\n if (action.definition && !content.includes(action.definition.marker)) {\n issues.push(`${action.path} missing safeword link`);\n }\n } else {\n issues.push(`${action.path} file missing`);\n }\n }\n return issues;\n}\n\ninterface HealthStatus {\n configured: boolean;\n projectVersion: string | undefined;\n cliVersion: string;\n updateAvailable: boolean;\n latestVersion: string | undefined;\n issues: string[];\n /**\n * Non-blocking diagnostics — reported to the user but do NOT gate\n * non-zero exit. Use for situations where safeword's operation is\n * unaffected but a cleanup or attention is warranted (e.g., legacy\n * default-location file orphaned by a configured `paths.*` override).\n */\n advisories: string[];\n missingPackages: string[];\n missingPacks: string[];\n}\n\n/**\n * Check for latest version from npm (with timeout)\n * @param timeout\n */\nasync function checkLatestVersion(timeout = 3000): Promise<string | undefined> {\n try {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => {\n controller.abort();\n }, timeout);\n\n const response = await fetch('https://registry.npmjs.org/safeword/latest', {\n signal: controller.signal,\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok) return undefined;\n\n const data = (await response.json()) as { version?: string };\n return data.version ?? undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Check project configuration health using reconcile dryRun\n * @param cwd\n */\nasync function checkHealth(cwd: string): Promise<HealthStatus> {\n const safewordDirectory = nodePath.join(cwd, '.safeword');\n\n // Check if configured\n if (!exists(safewordDirectory)) {\n return {\n configured: false,\n projectVersion: undefined,\n cliVersion: VERSION,\n updateAvailable: false,\n latestVersion: undefined,\n issues: [],\n advisories: [],\n missingPackages: [],\n missingPacks: [],\n };\n }\n\n // Read project version\n const versionPath = nodePath.join(safewordDirectory, 'version');\n const projectVersion = readFileSafe(versionPath)?.trim() ?? undefined;\n\n // Use reconcile with dryRun to detect issues\n const ctx = createProjectContext(cwd);\n const result = await reconcile(SAFEWORD_SCHEMA, 'upgrade', ctx, {\n dryRun: true,\n });\n\n // Collect issues from write actions and text patches\n // Filter out chmod (paths[] instead of path) and json-merge/unmerge (incompatible definition)\n const actionsWithPath = result.actions.filter(\n (\n a,\n ): a is Exclude<\n (typeof result.actions)[number],\n { type: 'chmod' } | { type: 'json-merge' } | { type: 'json-unmerge' }\n > => a.type !== 'chmod' && a.type !== 'json-merge' && a.type !== 'json-unmerge',\n );\n const issues: string[] = [\n ...findMissingFiles(cwd, actionsWithPath),\n ...findMissingPatches(cwd, actionsWithPath),\n ...findPersonaIssues(cwd),\n ...findGlossaryIssues(cwd),\n ];\n\n // Check for missing .claude/settings.json\n if (!exists(nodePath.join(cwd, '.claude', 'settings.json'))) {\n issues.push('Missing: .claude/settings.json');\n }\n\n // Check for missing language packs\n const missingPacks = getMissingPacks(cwd);\n\n return {\n configured: true,\n projectVersion,\n cliVersion: VERSION,\n updateAvailable: false,\n latestVersion: undefined,\n issues,\n advisories: [\n ...findPersonaAdvisories(cwd),\n ...findGlossaryAdvisories(cwd),\n ...findCoverageAdvisories(cwd),\n ...findRelationAdvisories(cwd),\n ...findArchitectureAdvisories(cwd),\n ],\n missingPackages: result.packagesToInstall,\n missingPacks,\n };\n}\n\n/**\n * Check for CLI updates and report status\n * @param health\n */\nasync function reportUpdateStatus(health: HealthStatus): Promise<void> {\n info('\\nChecking for updates...');\n const latestVersion = await checkLatestVersion();\n\n if (!latestVersion) {\n warn(\"Couldn't check for updates (offline?)\");\n return;\n }\n\n health.latestVersion = latestVersion;\n health.updateAvailable = isNewerVersion(health.cliVersion, latestVersion);\n\n if (health.updateAvailable) {\n warn(`Update available: v${latestVersion}`);\n info('Run `bunx safeword@latest upgrade` to upgrade');\n } else {\n success('CLI is up to date');\n }\n}\n\n/**\n * Compare project version vs CLI version and report\n * @param health\n */\nfunction reportVersionMismatch(health: HealthStatus): void {\n if (!health.projectVersion) return;\n\n if (isNewerVersion(health.cliVersion, health.projectVersion)) {\n warn(`Project config (v${health.projectVersion}) is newer than CLI (v${health.cliVersion})`);\n info('Consider upgrading the CLI');\n } else if (isNewerVersion(health.projectVersion, health.cliVersion)) {\n info(`\\nUpgrade available for project config`);\n info(\n `Run \\`safeword upgrade\\` to update from v${health.projectVersion} to v${health.cliVersion}`,\n );\n }\n}\n\n/**\n * Report issues or success\n * @param health\n * @returns true if there are issues requiring attention\n */\nfunction reportHealthSummary(health: HealthStatus): boolean {\n // Check missing packs first (highest priority - explains missing files)\n if (health.missingPacks.length > 0) {\n header('Missing Language Packs');\n for (const pack of health.missingPacks) {\n listItem(`${pack} pack not installed`);\n }\n info('\\nRun `safeword upgrade` to install missing packs');\n return true;\n }\n\n if (health.missingPackages.length > 0) {\n header('Missing Packages');\n for (const pkg of health.missingPackages) listItem(pkg);\n info('\\nRun `safeword upgrade` to install missing packages');\n return true;\n }\n\n if (health.issues.length > 0) {\n header('Issues Found');\n for (const issue of health.issues) {\n warn(issue);\n }\n info('\\nRun `safeword upgrade` to repair configuration');\n return true;\n }\n\n // Advisories: non-blocking diagnostics. Reported even when issues\n // exist (no early-return above this point handles them); printed\n // here when the project is otherwise healthy.\n if (health.advisories.length > 0) {\n header('Advisories');\n for (const advisory of health.advisories) {\n warn(advisory);\n }\n }\n\n success('\\nConfiguration is healthy');\n return false;\n}\n\n/**\n * Regenerate the ticket discovery index, swallowing any error — index\n * freshness must never block or fail a health check. Reports only when it\n * actually rewrote a file.\n * @param cwd\n */\nfunction regenerateTicketIndex(cwd: string): void {\n try {\n const result = syncTickets(cwd);\n if (result.wrote) {\n info('Regenerated ticket index (INDEX.md / INDEX-completed.md)');\n }\n } catch (error: unknown) {\n // Best-effort: index freshness must never fail the health check. Surface\n // under DEBUG, then return — the deliberate swallow point.\n if (process.env.DEBUG) {\n console.error('[check] ticket index regen failed:', error);\n }\n return;\n }\n}\n\n/**\n *\n * @param options\n */\nexport async function check(options: CheckOptions): Promise<void> {\n const cwd = process.cwd();\n\n header('Safeword Health Check');\n\n const health = await checkHealth(cwd);\n\n // Not configured\n if (!health.configured) {\n info('Not configured. Run `safeword setup` to initialize.');\n return;\n }\n\n // Keep the ticket discovery index fresh at this checkpoint (best-effort —\n // never fail the health check on index regen). Ticket 1GGD28.\n regenerateTicketIndex(cwd);\n\n // Show versions\n keyValue('Safeword CLI', `v${health.cliVersion}`);\n keyValue('Project config', health.projectVersion ? `v${health.projectVersion}` : 'unknown');\n\n // Check for updates (unless offline)\n if (options.offline) {\n info('\\nSkipped update check (offline mode)');\n } else {\n await reportUpdateStatus(health);\n }\n\n reportVersionMismatch(health);\n const hasIssues = reportHealthSummary(health);\n\n if (hasIssues) {\n process.exit(1);\n }\n}\n","/**\n * Lists a project's architecture records (ticket K4BWTQ).\n *\n * The resolved `paths.architecture` location may be a single markdown file\n * (the architecture record itself) or a directory of ADRs — each top-level\n * `.md` file except README.md, accept-any naming, no recursion. See the\n * M6D315 replan for why this reuses `paths.architecture` instead of a\n * separate ADR-location field.\n */\n\nimport { readdirSync, statSync } from 'node:fs';\nimport nodePath from 'node:path';\n\ntype ArchitectureLocationKind = 'file' | 'directory' | 'absent';\n\nexport interface ArchitectureRecords {\n kind: ArchitectureLocationKind;\n /** Absolute paths of the record files; empty when none exist. */\n records: string[];\n}\n\nexport function listArchitectureRecords(resolvedPath: string): ArchitectureRecords {\n // try/catch in addition to throwIfNoEntry: statSync still throws ENOTDIR\n // when a path *component* is a file (nodejs/node#56993) — reachable here\n // via a misconfigured paths.architecture; treat it as absent, not a crash.\n let stats;\n try {\n stats = statSync(resolvedPath, { throwIfNoEntry: false });\n } catch {\n return { kind: 'absent', records: [] };\n }\n if (stats?.isFile()) {\n return { kind: 'file', records: [resolvedPath] };\n }\n if (stats?.isDirectory()) {\n const records = readdirSync(resolvedPath, { withFileTypes: true })\n .filter(entry => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md')\n .map(entry => nodePath.join(resolvedPath, entry.name));\n return { kind: 'directory', records };\n }\n return { kind: 'absent', records: [] };\n}\n","/**\n * Glossary file model — parsing, validation, and lookup.\n *\n * Project-level glossary lives in `.safeword-project/glossary.md` (or the\n * path configured at `paths.glossary` in `.safeword/config.json`). Each\n * entry is a level-2 markdown block with a `## Term` header, a required\n * `**Definition:**` line, and optional `**Used in:**`, `**Example:**`,\n * `**Do not confuse with:**`, and `**Aliases:**` lines.\n *\n * Schema is intentionally lenient — unknown `**Field:**` lines are\n * tolerated for forward-compat, and the arcade-prototype\n * `**Used in**:` (colon outside the bold) variant parses identically\n * to `**Used in:**`. The required schema is just `## Term` + Definition;\n * everything else evolves per-team.\n *\n * See ticket YR6C49 for the full spec.\n */\n\nimport { readFileSync } from 'node:fs';\nimport nodePath from 'node:path';\n\nimport { resolveConfiguredPath } from './configured-paths.js';\nimport { computeSkipMask, stripInlineComments } from './markdown-sections.js';\nimport { findDuplicates, groupByLine } from './validation.js';\n\n/**\n * A parsed glossary entry — name + Definition (required), plus any\n * optional fields the entry authored. Aliases is always present\n * (possibly empty) so callers can iterate without an optional-chain.\n */\nexport interface ParsedGlossaryEntry {\n name: string;\n definition: string;\n usedIn?: string;\n example?: string;\n doNotConfuseWith?: string;\n aliases: string[];\n /** 1-indexed line number of the `## ` header. */\n lineNumber: number;\n}\n\n/** A validation error with a 1-indexed line reference into the source content. */\nexport interface GlossaryValidationError {\n line: number;\n message: string;\n}\n\n/**\n * Result of resolving a glossary reference against the parsed entries.\n *\n * Discriminated union — `match` is guaranteed when `status === 'valid'`;\n * `suggestion` is only meaningful when `status === 'unknown'`. Callers\n * narrow on `status` without optional chaining.\n */\nexport type GlossaryReferenceResult =\n | { status: 'valid'; match: ParsedGlossaryEntry }\n | { status: 'unknown'; suggestion?: string };\n\n/** Path of glossary.md relative to the project root (default location). */\nexport const GLOSSARY_FILE_SUBPATH = ['.safeword-project', 'glossary.md'];\n\n/**\n * Resolve a glossary reference against the on-disk glossary file.\n *\n * Reads from `paths.glossary` in `.safeword/config.json` when configured;\n * falls back to `.safeword-project/glossary.md` otherwise. Degrades\n * gracefully on a missing or unreadable file — returns\n * `{ status: 'unknown' }` rather than throwing, regardless of whether the\n * resolved path is the default or a configured override. The loud signal\n * on configured-but-missing lives in `safeword check`, not here — keep\n * this lookup cheap and side-effect-free (mirrors\n * `validatePersonaReference`, ticket K7N2QM).\n */\nexport function validateGlossaryReference(cwd: string, input: string): GlossaryReferenceResult {\n let content: string;\n try {\n const filePath = resolveConfiguredPath(\n cwd,\n 'glossary',\n nodePath.join(...GLOSSARY_FILE_SUBPATH),\n );\n content = readFileSync(filePath, 'utf8');\n } catch {\n return { status: 'unknown' };\n }\n return lookupGlossaryReference(parseGlossary(content), input);\n}\n\n/**\n * Look up a glossary reference against parsed entries.\n *\n * Match priority: exact term name → exact alias → casing-mismatch\n * suggestion → unknown. Pure — no I/O.\n */\nexport function lookupGlossaryReference(\n entries: readonly ParsedGlossaryEntry[],\n input: string,\n): GlossaryReferenceResult {\n if (input.length === 0) return { status: 'unknown' };\n\n for (const entry of entries) {\n if (entry.name === input || entry.aliases.includes(input)) {\n return { status: 'valid', match: entry };\n }\n }\n\n // Casing-mismatch detection — suggest the canonical spelling when the\n // only difference is case (on a term name or an alias).\n const lowered = input.toLowerCase();\n for (const entry of entries) {\n if (entry.name.toLowerCase() === lowered) {\n return { status: 'unknown', suggestion: entry.name };\n }\n const aliasMatch = entry.aliases.find(alias => alias.toLowerCase() === lowered);\n if (aliasMatch !== undefined) {\n return { status: 'unknown', suggestion: entry.name };\n }\n }\n\n return { status: 'unknown' };\n}\n\n/**\n * Group alias → header line numbers across all entries. Unlike\n * {@link groupByLine} (one key per entry), each entry contributes one key\n * per declared alias.\n */\nfunction groupAliasesByLine(entries: readonly ParsedGlossaryEntry[]): Map<string, number[]> {\n const grouped = new Map<string, number[]>();\n for (const entry of entries) {\n for (const alias of entry.aliases) {\n if (alias.length === 0) continue;\n const lines = grouped.get(alias) ?? [];\n lines.push(entry.lineNumber);\n grouped.set(alias, lines);\n }\n }\n return grouped;\n}\n\n/**\n * Flag aliases that collide with a declared term name. Lookup must\n * resolve a string to exactly one term; an alias that shadows a real\n * term name is ambiguous. A self-alias (alias equal to its own term's\n * name) is harmless redundancy and not flagged.\n */\nfunction findAliasShadowingTerms(\n entries: readonly ParsedGlossaryEntry[],\n): GlossaryValidationError[] {\n const termLines = new Map<string, number>();\n for (const entry of entries) {\n if (entry.name.length > 0 && !termLines.has(entry.name)) {\n termLines.set(entry.name, entry.lineNumber);\n }\n }\n const errors: GlossaryValidationError[] = [];\n for (const entry of entries) {\n for (const alias of entry.aliases) {\n const termLine = termLines.get(alias);\n if (termLine !== undefined && termLine !== entry.lineNumber) {\n errors.push({\n line: entry.lineNumber,\n message: `alias \"${alias}\" shadows term defined at line ${termLine}`,\n });\n }\n }\n }\n return errors;\n}\n\n/**\n * Validate parsed glossary entries. Returns a list of\n * {@link GlossaryValidationError} with 1-indexed line numbers; empty list\n * means the file is well-formed.\n *\n * Checks (each independent, all errors collected — never throws):\n * - Every entry has a non-empty term name.\n * - Every entry has a non-empty `**Definition:**`.\n * - Term names are unique within the file.\n * - Aliases are unique across all terms.\n * - No alias shadows a declared term name (ambiguous lookup).\n */\nexport function validateGlossary(\n entries: readonly ParsedGlossaryEntry[],\n): GlossaryValidationError[] {\n const errors: GlossaryValidationError[] = [];\n for (const entry of entries) {\n if (entry.name.length === 0) {\n errors.push({ line: entry.lineNumber, message: 'header is missing term name' });\n }\n if (entry.definition.trim().length === 0) {\n const label = entry.name.length === 0 ? 'entry' : `\"${entry.name}\"`;\n errors.push({ line: entry.lineNumber, message: `${label} is missing Definition` });\n }\n }\n errors.push(\n ...findDuplicates(\n groupByLine(entries, entry => entry.name),\n 'term',\n ),\n ...findDuplicates(groupAliasesByLine(entries), 'alias'),\n ...findAliasShadowingTerms(entries),\n );\n return errors;\n}\n\n/**\n * The string-valued fields a `**Field:**` line can populate. Aliases is\n * excluded — it parses to an array and does not accumulate across lines.\n */\ntype StringFieldKey = 'definition' | 'usedIn' | 'example' | 'doNotConfuseWith';\n\n/**\n * Maps a `**Field:**` prefix to the corresponding property on\n * `ParsedGlossaryEntry`. Lookup is by exact-prefix; unknown prefixes are\n * silently ignored (forward-compat per ticket scope).\n */\nconst FIELD_PROPERTY_MAP: ReadonlyMap<string, StringFieldKey> = new Map([\n ['**Definition:**', 'definition'],\n ['**Used in:**', 'usedIn'],\n ['**Example:**', 'example'],\n ['**Do not confuse with:**', 'doNotConfuseWith'],\n]);\n\n/**\n * Normalize the colon-outside variant `**Foo**:` to the canonical\n * colon-inside form `**Foo:**` so a single prefix lookup table covers\n * both. Arcade's prototype glossary mixes both conventions on adjacent\n * lines — the parser must tolerate either.\n *\n * Bounded: only inspects the leading `**...**:` segment; no backtracking.\n */\nfunction normalizeFieldColon(line: string): string {\n if (!line.startsWith('**')) return line;\n const closeBold = line.indexOf('**', 2);\n if (closeBold === -1) return line;\n if (line.charAt(closeBold + 2) !== ':') return line;\n // Splice: `<prefix>**` + `:**` + `<rest after `**:`>` →\n // `**Foo**: bar` becomes `**Foo:** bar`.\n return `${line.slice(0, closeBold)}:**${line.slice(closeBold + 3)}`;\n}\n\n/**\n * If the line begins with one of the known `**Field:**` prefixes, return\n * the property + value to assign. Otherwise return undefined.\n */\nfunction parseFieldLine(line: string): { property: StringFieldKey; value: string } | undefined {\n const normalized = normalizeFieldColon(line);\n for (const [prefix, property] of FIELD_PROPERTY_MAP) {\n if (normalized.startsWith(prefix)) {\n return { property, value: normalized.slice(prefix.length).trim() };\n }\n }\n return undefined;\n}\n\n/**\n * Whether a line looks like a `**Field:**` declaration (known or not).\n * Used to terminate continuation accumulation on an unknown field line\n * so it isn't swallowed into the previous field's value. Accepts the\n * colon-outside variant via normalization first.\n */\nfunction looksLikeFieldDeclaration(line: string): boolean {\n const normalized = normalizeFieldColon(line);\n if (!normalized.startsWith('**')) return false;\n // Require non-empty content between the opening `**` and the `:**` close.\n return normalized.indexOf(':**') > 2;\n}\n\n/**\n * Parse the comma-separated alias list from a `**Aliases:** foo, bar` line.\n * Empty trailing-whitespace yields an empty list. Returns undefined when\n * the line isn't an Aliases line.\n */\nfunction parseAliasLine(line: string): string[] | undefined {\n if (!line.startsWith('**Aliases:**')) return undefined;\n const raw = line.slice('**Aliases:**'.length).trim();\n return raw.length === 0 ? [] : raw.split(',').map(part => part.trim());\n}\n\n/**\n * Outcome of applying one line to the active entry:\n * - `field` — a string field was set; the caller accumulates continuation\n * lines into `field`.\n * - `aliases` — the aliases line was consumed; stop accumulating.\n * - `none` — no known prefix matched; the line is a continuation candidate.\n */\ntype LineOutcome =\n | { kind: 'field'; field: StringFieldKey }\n | { kind: 'aliases' }\n | { kind: 'none' };\n\n/**\n * Apply a recognized field/alias line to the active entry. Unknown\n * `**Field:**` lines are tolerated per ticket scope (returns `none`).\n */\nfunction applyLineToEntry(line: string, entry: ParsedGlossaryEntry): LineOutcome {\n const aliases = parseAliasLine(line);\n if (aliases !== undefined) {\n entry.aliases = aliases;\n return { kind: 'aliases' };\n }\n const field = parseFieldLine(line);\n if (field) {\n entry[field.property] = field.value;\n return { kind: 'field', field: field.property };\n }\n return { kind: 'none' };\n}\n\n/**\n * Append a continuation line to the active string field, soft-wrap style:\n * single space between the existing text and the trimmed continuation.\n */\nfunction appendContinuation(entry: ParsedGlossaryEntry, field: StringFieldKey, line: string): void {\n const existing = entry[field] ?? '';\n const addition = line.trim();\n entry[field] = existing.length === 0 ? addition : `${existing} ${addition}`;\n}\n\n/**\n * Apply one body line (a line within a `## Term` block) to the active\n * entry and return the field that should accumulate subsequent\n * continuation lines. A blank line, an aliases line, or an unknown\n * `**Field:**` declaration resets accumulation (returns undefined).\n */\nfunction consumeBodyLine(\n line: string,\n entry: ParsedGlossaryEntry,\n activeField: StringFieldKey | undefined,\n): StringFieldKey | undefined {\n if (line.trim().length === 0) return undefined;\n const outcome = applyLineToEntry(line, entry);\n if (outcome.kind === 'field') return outcome.field;\n if (outcome.kind === 'aliases' || looksLikeFieldDeclaration(line)) return undefined;\n if (activeField !== undefined) appendContinuation(entry, activeField, line);\n return activeField;\n}\n\n/**\n * If the line is a level-2 header (`## Name`, or a bare/empty `##`),\n * return the (possibly empty) term name with inline comments stripped.\n * Returns undefined for non-header lines. An empty name is surfaced as a\n * validation error downstream, not dropped here — so the bad line still\n * produces an entry the validator can point at.\n */\nfunction parseTermHeader(line: string): string | undefined {\n if (line === '##') return '';\n if (line.startsWith('## ')) return stripInlineComments(line.slice(3)).trim();\n return undefined;\n}\n\n/**\n * Parse glossary entries from markdown content.\n *\n * Walks lines once, tracking the active `## Term` block. Skip-mask hides\n * fenced code and block HTML comments. Inline HTML comments are stripped\n * from header text before name extraction. Known `**Field:**` lines (plus\n * the arcade colon-outside variant) populate the matching property on the\n * active entry; unknown `**Field:**` lines are silently tolerated. Pure\n * — no I/O.\n */\nexport function parseGlossary(content: string): ParsedGlossaryEntry[] {\n const lines = content.split('\\n');\n const skip = computeSkipMask(lines);\n const entries: ParsedGlossaryEntry[] = [];\n let current: ParsedGlossaryEntry | undefined;\n // The field currently accumulating continuation lines. Reset on a blank\n // line, a new `## ` header, or an aliases line.\n let activeField: StringFieldKey | undefined;\n\n for (const [index, line] of lines.entries()) {\n if (skip[index]) continue;\n const headerName = parseTermHeader(line);\n if (headerName !== undefined) {\n if (current) entries.push(current);\n current = {\n name: headerName,\n definition: '',\n aliases: [],\n lineNumber: index + 1,\n };\n activeField = undefined;\n continue;\n }\n if (!current) continue;\n activeField = consumeBodyLine(line, current, activeField);\n }\n\n if (current) entries.push(current);\n return entries;\n}\n","/**\n * Shared validation helpers for the `## `-block file models (personas,\n * glossary). Both group parsed entries by a key and flag duplicates with a\n * uniform `duplicate <kind> \"<value>\" (also at line <others>)` message.\n * Extracted per ticket JZXVKN (Rule of Three, after WQ4RH3's skip-mask lift).\n */\n\n/** A validation finding with a 1-indexed line reference into the source. */\nexport interface ValidationIssue {\n line: number;\n message: string;\n}\n\n/**\n * Group entries by a derived key → the 1-indexed header line numbers that\n * produced it. Empty keys are skipped. Works for any parsed entry carrying a\n * `lineNumber` (ParsedPersona, ParsedGlossaryEntry, …).\n */\nexport function groupByLine<T extends { lineNumber: number }>(\n entries: readonly T[],\n pick: (entry: T) => string,\n): Map<string, number[]> {\n const grouped = new Map<string, number[]>();\n for (const entry of entries) {\n const key = pick(entry);\n if (key.length === 0) continue;\n const lines = grouped.get(key) ?? [];\n lines.push(entry.lineNumber);\n grouped.set(key, lines);\n }\n return grouped;\n}\n\n/**\n * Produce duplicate-detection issues from a grouping (key → header line\n * numbers): every key with more than one line yields one issue per line,\n * naming the others. `kind` labels the value class (e.g. \"persona name\",\n * \"persona code\", \"term\", \"alias\").\n */\nexport function findDuplicates(grouped: Map<string, number[]>, kind: string): ValidationIssue[] {\n const issues: ValidationIssue[] = [];\n for (const [value, lines] of grouped.entries()) {\n if (lines.length <= 1) continue;\n for (const line of lines) {\n const others = lines.filter(other => other !== line).join(', ');\n issues.push({ line, message: `duplicate ${kind} \"${value}\" (also at line ${others})` });\n }\n }\n return issues;\n}\n","/**\n * Persona file model — derivation, parsing, validation, and lookup.\n *\n * Project-level personas live in `.safeword-project/personas.md` as\n * second-level markdown blocks. Each block has a name, an optional\n * parenthesized short code (auto-derived if absent), a `**Role:**` line,\n * and an optional `**Context:**` block.\n *\n * Short codes follow the pattern `^[A-Z][A-Z0-9]{1,5}$` — 2-6 chars,\n * uppercase letter first, then letters and digits. Codes are derived\n * conventionally from the name (first-letter-of-each-word for multi-word,\n * first-2-chars for single-word), with non-alpha characters stripped before\n * derivation. Users can override the derived code with explicit\n * `## Name (CODE)` syntax.\n *\n * See ticket 7YN5QB for the full spec.\n */\n\nimport { readFileSync } from 'node:fs';\nimport nodePath from 'node:path';\n\nimport { resolveConfiguredPath } from './configured-paths.js';\nimport { computeSkipMask, stripInlineComments } from './markdown-sections.js';\nimport { findDuplicates, groupByLine } from './validation.js';\n\n// The three constants below are exported for workspace-internal use (tests\n// asserting the canonical bounds, docs referencing them without hardcoding,\n// future code in the same package). They are deliberately NOT re-exported\n// from `src/presets/typescript/index.ts` — customers interact with persona\n// validation through `safeword check`, not by reading these constants\n// directly. Promoting them to safeword's public preset surface would make\n// the values part of safeword's semver contract (changing 6 → 8 would\n// become a breaking change), and there's no current consumer that needs\n// that commitment.\n\n/** Maximum length of a derived short code (overflow is truncated silently). */\nexport const MAX_CODE_LENGTH = 6;\n/** Minimum persona name length — single-char names are rejected at validation. */\nexport const MIN_NAME_LENGTH = 2;\n/** Pattern for a valid persona short code. */\nexport const PERSONA_CODE_PATTERN = /^[A-Z][A-Z0-9]{1,5}$/;\n\n/**\n * Derive a short code from a persona name.\n *\n * Multi-word names use first-letter-of-each-word (\"Platform Operator\" → \"PO\").\n * Single-word names use first-2-chars uppercased (\"Auditor\" → \"AU\").\n * Non-alpha characters (apostrophes, hyphens) are stripped before derivation;\n * digits are preserved within the resulting code.\n * Overflow is truncated to the first {@link MAX_CODE_LENGTH} characters.\n *\n * Note: the returned code may not pass {@link PERSONA_CODE_PATTERN} for\n * pathological inputs (e.g., digit-first names like \"3 Amigos\" → \"3A\").\n * Pattern enforcement happens at validation time, not derivation time.\n */\nexport function derivePersonaCode(name: string): string {\n const trimmed = name.trim();\n if (trimmed.length === 0) return '';\n\n // Strip non-alphanumeric except whitespace — keeps digits, removes\n // apostrophes/hyphens/punctuation. Whitespace remains as the word separator.\n const cleaned = trimmed.replaceAll(/[^A-Z0-9\\s]/gi, '');\n const words = cleaned.split(/\\s+/).filter(word => word.length > 0);\n\n const [firstWord] = words;\n if (!firstWord) return '';\n\n // String.charAt returns '' for empty strings — no narrowing needed and\n // no non-null assertion (each word is non-empty per the filter above,\n // but TypeScript can't prove that on indexed access).\n const derived =\n words.length === 1 ? firstWord.slice(0, 2) : words.map(word => word.charAt(0)).join('');\n\n return derived.toUpperCase().slice(0, MAX_CODE_LENGTH);\n}\n\n/** Whether a persona name passes the minimum-length requirement. */\nexport function isValidPersonaName(name: string): boolean {\n return name.trim().length >= MIN_NAME_LENGTH;\n}\n\n/** Whether a code matches the persona-code pattern. */\nexport function isValidPersonaCode(code: string): boolean {\n return PERSONA_CODE_PATTERN.test(code);\n}\n\n/**\n * A parsed persona block — name, code (possibly empty before resolution),\n * line number of the header (1-indexed), and whether the user explicitly\n * authored the code via `## Name (CODE)` syntax.\n */\nexport interface ParsedPersona {\n name: string;\n /** Empty string when no code was authored (will be filled by {@link resolvePersonaCodes}). */\n rawCode: string;\n /** True when the code came from `## Name (CODE)` syntax; false when absent in source. */\n explicit: boolean;\n /** 1-indexed line number of the `## ` header. */\n lineNumber: number;\n /** Whether a `**Role:**` line was found in the block body. */\n hasRole: boolean;\n}\n\n/** A resolved persona — code is always populated (derived if not explicit). */\nexport interface ResolvedPersona extends ParsedPersona {\n code: string;\n}\n\n/** A validation error with a 1-indexed line reference into the source content. */\nexport interface PersonaValidationError {\n line: number;\n message: string;\n}\n\n/**\n * Extract name and (optional) code from a `## ...` header line.\n *\n * Parsed manually rather than with regex to avoid super-linear-backtracking\n * vulnerabilities flagged by `regexp/no-super-linear-backtracking`. The\n * `(CODE)` suffix is detected by checking for a trailing `)` and locating\n * its matching `(` via `lastIndexOf` — no quantifier overlap. Inline HTML\n * comments are stripped from the body before name/code extraction so a\n * trailing `<!-- ... -->` doesn't corrupt the parsed name.\n */\nfunction parseHeaderLine(line: string): { name: string; rawCode: string | undefined } | undefined {\n if (!line.startsWith('## ')) return undefined;\n const body = stripInlineComments(line.slice(3)).trimEnd();\n if (body.endsWith(')')) {\n const openParen = body.lastIndexOf('(');\n if (openParen !== -1) {\n const namePart = body.slice(0, openParen).trim();\n const codePart = body.slice(openParen + 1, -1).trim();\n return { name: namePart, rawCode: codePart };\n }\n }\n return { name: body.trim(), rawCode: undefined };\n}\n\n/**\n * Parse persona blocks from markdown content.\n *\n * A block starts at a level-2 header (`## ...`) and runs until the next\n * level-2 header or end of file. The header may include a parenthesized\n * code (`## Name (PO)`) or omit it (`## Name`). The body is scanned for\n * a `**Role:**` line; presence is recorded but the role text isn't\n * extracted here (validation only needs the existence check).\n *\n * Pure — no I/O.\n */\nexport function parsePersonas(content: string): ParsedPersona[] {\n const lines = content.split('\\n');\n const skip = computeSkipMask(lines);\n const personas: ParsedPersona[] = [];\n let current: ParsedPersona | undefined;\n\n for (const [index, line] of lines.entries()) {\n if (skip[index]) continue;\n const header = parseHeaderLine(line);\n if (header) {\n if (current) personas.push(current);\n current = {\n name: header.name,\n rawCode: header.rawCode ?? '',\n explicit: header.rawCode !== undefined,\n lineNumber: index + 1,\n hasRole: false,\n };\n continue;\n }\n if (current && line.startsWith('**Role:**')) {\n current.hasRole = true;\n }\n }\n\n if (current) personas.push(current);\n return personas;\n}\n\n/**\n * Resolve auto-derived codes with collision avoidance.\n *\n * For each persona without an explicit code, derive one from the name.\n * If the derived code is already taken (by a user-authored explicit code\n * or a previously-resolved derivation in the same pass), append a numeric\n * suffix starting at 2 (`PO` → `PO2` → `PO3` → ...).\n *\n * Explicit codes are claimed up-front so derived codes always lose\n * collision disputes against user-authored ones.\n */\nexport function resolvePersonaCodes(parsed: readonly ParsedPersona[]): ResolvedPersona[] {\n const claimed = new Set<string>();\n for (const persona of parsed) {\n if (persona.explicit && persona.rawCode.length > 0) {\n claimed.add(persona.rawCode);\n }\n }\n\n const resolved: ResolvedPersona[] = [];\n for (const persona of parsed) {\n if (persona.explicit) {\n resolved.push({ ...persona, code: persona.rawCode });\n continue;\n }\n const base = derivePersonaCode(persona.name);\n let candidate = base;\n let suffix = 2;\n while (claimed.has(candidate)) {\n candidate = `${base}${suffix}`;\n suffix += 1;\n }\n claimed.add(candidate);\n resolved.push({ ...persona, code: candidate });\n }\n\n return resolved;\n}\n\n/**\n * Validate parsed personas. Returns a list of {@link PersonaValidationError}\n * with 1-indexed line numbers; empty list means the file is well-formed.\n *\n * Checks (each independent):\n * - Persona name is ≥ {@link MIN_NAME_LENGTH} characters\n * - Header has a name (not just `## (CODE)`)\n * - Block has a `**Role:**` line\n * - Persona names are unique within the file\n * - Resolved codes are unique within the file\n * - Resolved codes match {@link PERSONA_CODE_PATTERN}\n * (digit-first names like \"3 Amigos\" derive non-conformant codes and\n * surface here with the explicit-override prompt)\n */\nfunction validateNameAndRole(persona: ParsedPersona): PersonaValidationError[] {\n const errors: PersonaValidationError[] = [];\n if (persona.name.length === 0) {\n errors.push({ line: persona.lineNumber, message: 'missing persona name' });\n } else if (!isValidPersonaName(persona.name)) {\n errors.push({\n line: persona.lineNumber,\n message: 'persona name must be at least 2 characters',\n });\n }\n if (!persona.hasRole) {\n errors.push({ line: persona.lineNumber, message: 'missing Role line' });\n }\n return errors;\n}\n\n/** Produce pattern-violation errors for resolved personas. */\nfunction findPatternErrors(resolved: readonly ResolvedPersona[]): PersonaValidationError[] {\n const errors: PersonaValidationError[] = [];\n for (const persona of resolved) {\n if (persona.code.length === 0) continue;\n if (isValidPersonaCode(persona.code)) continue;\n const message = persona.explicit\n ? `code \"${persona.code}\" violates pattern ${PERSONA_CODE_PATTERN.source}`\n : `name produces non-conformant code \"${persona.code}\" — author explicit code via \\`## Name (CODE)\\``;\n errors.push({ line: persona.lineNumber, message });\n }\n return errors;\n}\n\nexport function validatePersonas(parsed: readonly ParsedPersona[]): PersonaValidationError[] {\n const resolved = resolvePersonaCodes(parsed);\n return [\n ...parsed.flatMap(persona => validateNameAndRole(persona)),\n ...findDuplicates(\n groupByLine(parsed, persona => persona.name),\n 'persona name',\n ),\n ...findPatternErrors(resolved),\n ...findDuplicates(\n groupByLine(resolved, persona => persona.code),\n 'persona code',\n ),\n ];\n}\n\n/**\n * Result of resolving a persona reference against the file.\n *\n * Discriminated union — `match` is guaranteed when `status === 'valid'`;\n * `suggestion` is only meaningful (and only ever populated) when\n * `status === 'unknown'`. Callers can narrow without optional chaining\n * after checking `status`.\n */\nexport type PersonaReferenceResult =\n | { status: 'valid'; match: ResolvedPersona }\n | { status: 'unknown'; suggestion?: string };\n\n/** Path of personas.md relative to the project root. */\nexport const PERSONAS_FILE_SUBPATH = ['.safeword-project', 'personas.md'];\n\n/**\n * Look up a persona reference against a parsed-and-resolved list.\n *\n * Strict on casing: `\"po\"` against existing `PO` returns\n * `{ status: 'unknown', suggestion: 'PO' }`. Lenient matching would\n * silently alias persona codes that legitimately differ by case\n * (`PO` vs `Po` vs `PO2`).\n *\n * Match priority: exact code → exact name → casing-mismatch suggestion.\n *\n * Pure — no I/O. Wrap with {@link validatePersonaReference} for the file-reading\n * path.\n */\nexport function lookupPersonaReference(\n personas: readonly ResolvedPersona[],\n input: string,\n): PersonaReferenceResult {\n if (input.length === 0) return { status: 'unknown' };\n\n for (const persona of personas) {\n if (persona.code === input || persona.name === input) {\n return { status: 'valid', match: persona };\n }\n }\n\n // Casing-mismatch detection — search again with lowercase comparison.\n const lowered = input.toLowerCase();\n for (const persona of personas) {\n if (persona.code.toLowerCase() === lowered) {\n return { status: 'unknown', suggestion: persona.code };\n }\n if (persona.name.toLowerCase() === lowered) {\n return { status: 'unknown', suggestion: persona.name };\n }\n }\n\n return { status: 'unknown' };\n}\n\n/**\n * Resolve a persona reference against the on-disk personas file.\n *\n * Reads from `paths.personas` in `.safeword/config.json` when configured;\n * falls back to `.safeword-project/personas.md` otherwise. Degrades\n * gracefully on a missing or unreadable file — returns\n * `{ status: 'unknown' }` rather than throwing, regardless of whether the\n * resolved path is the default or a configured override. Strict\n * validation lives in `safeword check`; this lookup API is meant to be\n * cheap, consistent, and side-effect-free. Do NOT change the unknown\n * return to a throw for configured-but-missing — `safeword check` owns\n * the loud signal (ticket K7N2QM).\n */\nexport function validatePersonaReference(cwd: string, input: string): PersonaReferenceResult {\n let content: string;\n try {\n const filePath = resolveConfiguredPath(\n cwd,\n 'personas',\n nodePath.join(...PERSONAS_FILE_SUBPATH),\n );\n content = readFileSync(filePath, 'utf8');\n } catch {\n return { status: 'unknown' };\n }\n const personas = resolvePersonaCodes(parsePersonas(content));\n return lookupPersonaReference(personas, input);\n}\n"],"mappings":"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","names":["readdirSync","nodePath","nodePath","readFileSync","nodePath","header","nodePath","readdirSync"]}
@@ -2,14 +2,18 @@
2
2
  function info(message) {
3
3
  console.log(message);
4
4
  }
5
+ function formatGlyphLine(glyph, message) {
6
+ const leadingNewlines = /^\n*/.exec(message)?.[0] ?? "";
7
+ return `${leadingNewlines}${glyph} ${message.slice(leadingNewlines.length)}`;
8
+ }
5
9
  function success(message) {
6
- console.log(`\u2713 ${message}`);
10
+ console.log(formatGlyphLine("\u2713", message));
7
11
  }
8
12
  function warn(message) {
9
- console.warn(`\u26A0 ${message}`);
13
+ console.warn(formatGlyphLine("\u26A0", message));
10
14
  }
11
15
  function error(message) {
12
- console.error(`\u2717 ${message}`);
16
+ console.error(formatGlyphLine("\u2717", message));
13
17
  }
14
18
  function header(title) {
15
19
  console.log(`
@@ -32,4 +36,4 @@ export {
32
36
  listItem,
33
37
  keyValue
34
38
  };
35
- //# sourceMappingURL=chunk-VZ2E2QRM.js.map
39
+ //# sourceMappingURL=chunk-46XXWC64.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/utils/output.ts"],"sourcesContent":["/**\n * Console output utilities for consistent CLI messaging\n */\n\n/**\n * Print info message\n * @param message\n */\nexport function info(message: string): void {\n console.log(message);\n}\n\n/**\n * Compose a glyph-prefixed line, hoisting any leading newlines ABOVE the glyph\n * so blank-line spacing renders before the marker instead of orphaning it on\n * its own line (ticket 469YSR). `('✓', '\\nFoo')` → `'\\n✓ Foo'`.\n * @param glyph the status glyph (✓ / ⚠ / ✗)\n * @param message the message, which may start with newline(s) for spacing\n */\nexport function formatGlyphLine(glyph: string, message: string): string {\n const leadingNewlines = /^\\n*/.exec(message)?.[0] ?? '';\n return `${leadingNewlines}${glyph} ${message.slice(leadingNewlines.length)}`;\n}\n\n/**\n * Print success message\n * @param message\n */\nexport function success(message: string): void {\n console.log(formatGlyphLine('✓', message));\n}\n\n/**\n * Print warning message\n * @param message\n */\nexport function warn(message: string): void {\n console.warn(formatGlyphLine('⚠', message));\n}\n\n/**\n * Print error message to stderr\n * @param message\n */\nexport function error(message: string): void {\n console.error(formatGlyphLine('✗', message));\n}\n\n/**\n * Print a section header\n * @param title\n */\nexport function header(title: string): void {\n console.log(`\\n${title}`);\n console.log('─'.repeat(title.length));\n}\n\n/**\n * Print a list item\n * @param item\n * @param indent\n */\nexport function listItem(item: string, indent = 2): void {\n console.log(`${' '.repeat(indent)}• ${item}`);\n}\n\n/**\n * Print key-value pair\n * @param key\n * @param value\n */\nexport function keyValue(key: string, value: string): void {\n console.log(` ${key}: ${value}`);\n}\n"],"mappings":";AAQO,SAAS,KAAK,SAAuB;AAC1C,UAAQ,IAAI,OAAO;AACrB;AASO,SAAS,gBAAgB,OAAe,SAAyB;AACtE,QAAM,kBAAkB,OAAO,KAAK,OAAO,IAAI,CAAC,KAAK;AACrD,SAAO,GAAG,eAAe,GAAG,KAAK,IAAI,QAAQ,MAAM,gBAAgB,MAAM,CAAC;AAC5E;AAMO,SAAS,QAAQ,SAAuB;AAC7C,UAAQ,IAAI,gBAAgB,UAAK,OAAO,CAAC;AAC3C;AAMO,SAAS,KAAK,SAAuB;AAC1C,UAAQ,KAAK,gBAAgB,UAAK,OAAO,CAAC;AAC5C;AAMO,SAAS,MAAM,SAAuB;AAC3C,UAAQ,MAAM,gBAAgB,UAAK,OAAO,CAAC;AAC7C;AAMO,SAAS,OAAO,OAAqB;AAC1C,UAAQ,IAAI;AAAA,EAAK,KAAK,EAAE;AACxB,UAAQ,IAAI,SAAI,OAAO,MAAM,MAAM,CAAC;AACtC;AAOO,SAAS,SAAS,MAAc,SAAS,GAAS;AACvD,UAAQ,IAAI,GAAG,IAAI,OAAO,MAAM,CAAC,UAAK,IAAI,EAAE;AAC9C;AAOO,SAAS,SAAS,KAAa,OAAqB;AACzD,UAAQ,IAAI,KAAK,GAAG,KAAK,KAAK,EAAE;AAClC;","names":[]}
@@ -6,7 +6,7 @@ import {
6
6
  error,
7
7
  info,
8
8
  success
9
- } from "./chunk-VZ2E2QRM.js";
9
+ } from "./chunk-46XXWC64.js";
10
10
 
11
11
  // src/commands/sync-config.ts
12
12
  import { readFileSync, writeFileSync } from "fs";
@@ -313,4 +313,4 @@ export {
313
313
  hasArchitectureDetected,
314
314
  syncConfig
315
315
  };
316
- //# sourceMappingURL=chunk-XI4SIM76.js.map
316
+ //# sourceMappingURL=chunk-I7ONBYQU.js.map
@@ -1,3 +1,57 @@
1
+ import {
2
+ formatTicketReference
3
+ } from "./chunk-NHXVS5FL.js";
4
+
5
+ // src/utils/ticket-relations.ts
6
+ function parseTicketIdList(raw) {
7
+ if (raw === void 0) return [];
8
+ const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "");
9
+ return inner.split(",").map((id) => id.trim()).filter((id) => id.length > 0);
10
+ }
11
+ function deriveBlocks(nodes) {
12
+ const blocks = /* @__PURE__ */ new Map();
13
+ for (const node of nodes) {
14
+ for (const target of node.dependsOn) {
15
+ const blockers = blocks.get(target) ?? [];
16
+ blockers.push(node.id);
17
+ blocks.set(target, blockers);
18
+ }
19
+ }
20
+ return blocks;
21
+ }
22
+ function findDanglingDependencies(nodes) {
23
+ const known = new Set(nodes.map((node) => node.id));
24
+ const dangling = [];
25
+ for (const node of nodes) {
26
+ for (const target of node.dependsOn) {
27
+ if (!known.has(target)) dangling.push({ from: node.id, missing: target });
28
+ }
29
+ }
30
+ return dangling.toSorted(
31
+ (a, b) => a.from.localeCompare(b.from) || a.missing.localeCompare(b.missing)
32
+ );
33
+ }
34
+ function findTicketsInCycles(nodes) {
35
+ const edges = new Map(nodes.map((node) => [node.id, node.dependsOn]));
36
+ const inCycle = /* @__PURE__ */ new Set();
37
+ for (const start of edges.keys()) {
38
+ const stack = [...edges.get(start) ?? []];
39
+ const seen = /* @__PURE__ */ new Set();
40
+ while (stack.length > 0) {
41
+ const next = stack.pop();
42
+ if (next === void 0) continue;
43
+ if (next === start) {
44
+ inCycle.add(start);
45
+ break;
46
+ }
47
+ if (seen.has(next)) continue;
48
+ seen.add(next);
49
+ stack.push(...edges.get(next) ?? []);
50
+ }
51
+ }
52
+ return [...inCycle].toSorted((a, b) => a.localeCompare(b));
53
+ }
54
+
1
55
  // src/ticket-sync/index.ts
2
56
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
3
57
  import nodePath from "path";
@@ -60,7 +114,8 @@ function parseTicket(filePath, folder) {
60
114
  title,
61
115
  status,
62
116
  epic,
63
- goal: goalLine(bodyLines)
117
+ goal: goalLine(bodyLines),
118
+ dependsOn: parseTicketIdList(fields.get("depends_on"))
64
119
  }
65
120
  };
66
121
  }
@@ -94,10 +149,23 @@ function readTickets(ticketsDirectory) {
94
149
  skipped: [...active.skipped, ...completed.skipped]
95
150
  };
96
151
  }
97
- function renderEntry(entry) {
152
+ function renderRelation(ids, labelById) {
153
+ return ids.map((id) => {
154
+ const title = labelById.get(id);
155
+ return title === void 0 ? id : formatTicketReference(id, title);
156
+ }).join(", ");
157
+ }
158
+ function renderEntry(entry, blocks, labelById) {
98
159
  const epic = entry.epic ?? "\u2014";
99
- const lines = [`- **${entry.id}** \u2014 ${entry.title} (${entry.status}, epic: ${epic})`];
160
+ const lines = [
161
+ `- **${formatTicketReference(entry.id, entry.title)}** (${entry.status}, epic: ${epic})`
162
+ ];
100
163
  if (entry.goal !== void 0) lines.push(` ${entry.goal}`);
164
+ if (entry.dependsOn.length > 0) {
165
+ lines.push(` blocked by: ${renderRelation(entry.dependsOn, labelById)}`);
166
+ }
167
+ const blocking = blocks.get(entry.id) ?? [];
168
+ if (blocking.length > 0) lines.push(` blocks: ${renderRelation(blocking, labelById)}`);
101
169
  lines.push(` \u2192 \`${entry.relativePath}\``);
102
170
  return lines;
103
171
  }
@@ -127,10 +195,12 @@ function buildIndexContent(entries, options) {
127
195
  if (entries.length === 0) {
128
196
  return [...header, isActive ? "No active tickets." : "No completed tickets.", ""].join("\n");
129
197
  }
198
+ const blocks = deriveBlocks(entries);
199
+ const labelById = new Map(entries.map((entry) => [entry.id, entry.title]));
130
200
  const lines = [...header, `## Tickets (${entries.length})`, ""];
131
201
  for (const [epic, group] of groupByEpic(entries)) {
132
202
  lines.push(`### ${epic}`, "");
133
- for (const entry of group) lines.push(...renderEntry(entry));
203
+ for (const entry of group) lines.push(...renderEntry(entry, blocks, labelById));
134
204
  lines.push("");
135
205
  }
136
206
  return lines.join("\n");
@@ -163,6 +233,9 @@ function syncTickets(cwd) {
163
233
  }
164
234
 
165
235
  export {
236
+ findDanglingDependencies,
237
+ findTicketsInCycles,
238
+ readTickets,
166
239
  syncTickets
167
240
  };
168
- //# sourceMappingURL=chunk-QNLC7KYH.js.map
241
+ //# sourceMappingURL=chunk-K5EJJIPT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/utils/ticket-relations.ts","../src/ticket-sync/index.ts"],"sourcesContent":["/**\n * Structured ticket relations (ticket AKZJXC).\n *\n * One canonical directed edge — `depends_on` — stored as an inline-array scalar\n * the hand-rolled frontmatter parser can hold. The inverse (`blocks`) is always\n * derived across the corpus; cycles and dangling refs surface as warnings, never\n * errors (mirrors safeword's tolerant ID resolution).\n */\n\n/** A ticket reduced to its id and its outgoing `depends_on` edges. */\nexport interface TicketNode {\n id: string;\n dependsOn: string[];\n}\n\n/**\n * Parse a `depends_on` frontmatter scalar into ticket ids. Accepts the inline\n * array form (`[A, B]`) or a bare comma list (`A, B`); trims each id and drops\n * empties. Missing/empty input → `[]`.\n * @param raw the raw frontmatter value, or undefined when the key is absent\n */\nexport function parseTicketIdList(raw?: string): string[] {\n if (raw === undefined) return [];\n const inner = raw.trim().replace(/^\\[/, '').replace(/\\]$/, '');\n return inner\n .split(',')\n .map(id => id.trim())\n .filter(id => id.length > 0);\n}\n\n/**\n * Invert the `depends_on` graph into `id → ids that depend on it` (the derived\n * `blocks` edges). Only ids that block something appear as keys; each value\n * preserves corpus order.\n * @param nodes every ticket's id + depends_on edges\n */\nexport function deriveBlocks(nodes: TicketNode[]): Map<string, string[]> {\n const blocks = new Map<string, string[]>();\n for (const node of nodes) {\n for (const target of node.dependsOn) {\n const blockers = blocks.get(target) ?? [];\n blockers.push(node.id);\n blocks.set(target, blockers);\n }\n }\n return blocks;\n}\n\n/**\n * `depends_on` targets absent from the corpus, as `{from, missing}` pairs sorted\n * by from then missing. Warn-only — a target may live on another branch or in\n * completed/.\n * @param nodes every ticket's id + depends_on edges\n */\nexport function findDanglingDependencies(nodes: TicketNode[]): { from: string; missing: string }[] {\n const known = new Set(nodes.map(node => node.id));\n const dangling: { from: string; missing: string }[] = [];\n for (const node of nodes) {\n for (const target of node.dependsOn) {\n if (!known.has(target)) dangling.push({ from: node.id, missing: target });\n }\n }\n return dangling.toSorted(\n (a, b) => a.from.localeCompare(b.from) || a.missing.localeCompare(b.missing),\n );\n}\n\n/**\n * Sorted ids of tickets that participate in any `depends_on` cycle (a node\n * reachable from itself, including a self-edge). Warn-only. Dangling targets are\n * inert — they have no outgoing edges, so they can't form a cycle.\n * @param nodes every ticket's id + depends_on edges\n */\nexport function findTicketsInCycles(nodes: TicketNode[]): string[] {\n const edges = new Map(nodes.map(node => [node.id, node.dependsOn]));\n const inCycle = new Set<string>();\n\n for (const start of edges.keys()) {\n // DFS along depends_on edges; reaching `start` again means it's on a cycle.\n const stack = [...(edges.get(start) ?? [])];\n const seen = new Set<string>();\n while (stack.length > 0) {\n const next = stack.pop();\n if (next === undefined) continue;\n if (next === start) {\n inCycle.add(start);\n break;\n }\n if (seen.has(next)) continue;\n seen.add(next);\n stack.push(...(edges.get(next) ?? []));\n }\n }\n\n return [...inCycle].toSorted((a, b) => a.localeCompare(b));\n}\n","/**\n * Ticket sync — generates capability-discovery indexes over the ticket corpus:\n * `.safeword-project/tickets/INDEX.md` (active tickets, grouped by epic) and\n * `INDEX-completed.md` (the `completed/` archive). Mirrors `learning-sync`\n * (plain markdown + grep, no skill-description char cap) so \"is there already\n * a ticket for X?\" is one grep instead of a hundreds-of-folders hunt.\n *\n * Fired manually via `safeword sync-tickets`, as a `safeword check` step, and\n * after `ticket new`.\n *\n * Ticket 1GGD28.\n */\n\nimport { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport nodePath from 'node:path';\n\nimport { formatTicketReference } from '../utils/ticket-reference.js';\nimport { deriveBlocks, parseTicketIdList } from '../utils/ticket-relations.js';\n\nexport const TICKETS_RELATIVE_PATH = '.safeword-project/tickets';\nexport const INDEX_FILENAME = 'INDEX.md';\nexport const COMPLETED_INDEX_FILENAME = 'INDEX-completed.md';\nexport const COMPLETED_DIRNAME = 'completed';\n\nconst NO_EPIC_GROUP = '(no epic)';\nconst SKIP_DIRECTORIES = new Set([COMPLETED_DIRNAME, 'tmp']);\n\nexport interface TicketEntry {\n id: string;\n folder: string; // folder name, e.g. 1GGD28-ticket-discovery-index\n relativePath: string; // e.g. .safeword-project/tickets/1GGD28-ticket-discovery-index\n title: string;\n status: string;\n epic: string | undefined; // undefined → grouped under \"(no epic)\"\n goal: string | undefined; // the **Goal:** one-liner, when present\n dependsOn: string[]; // ticket ids this one depends on (directed edge); [] when none\n}\n\nexport interface TicketSyncResult {\n wrote: boolean;\n active: TicketEntry[];\n completed: TicketEntry[];\n skipped: { folder: string; reason: string }[];\n indexPath: string;\n completedIndexPath: string;\n}\n\n/** Strip a single layer of matching surrounding quotes. */\nfunction stripQuotes(value: string): string {\n if (\n value.length >= 2 &&\n ((value.startsWith(\"'\") && value.endsWith(\"'\")) ||\n (value.startsWith('\"') && value.endsWith('\"')))\n ) {\n return value.slice(1, -1);\n }\n return value;\n}\n\n/** Parse the leading `--- … ---` frontmatter block into a key→value map. */\nfunction parseFrontmatter(content: string): { fields: Map<string, string>; bodyStart: number } {\n const lines = content.split('\\n');\n const fields = new Map<string, string>();\n if (lines[0]?.trim() !== '---') return { fields, bodyStart: 0 };\n\n for (let index = 1; index < lines.length; index += 1) {\n const line = lines[index] ?? '';\n if (line.trim() === '---') return { fields, bodyStart: index + 1 };\n const match = /^([a-z_][\\w-]*):(.*)$/i.exec(line);\n if (match?.[1] !== undefined) fields.set(match[1], stripQuotes((match[2] ?? '').trim()));\n }\n return { fields, bodyStart: 0 };\n}\n\n/** First `# H1` heading text in the body, if any. */\nfunction firstHeading(bodyLines: string[]): string | undefined {\n for (const line of bodyLines) {\n if (line.startsWith('# ')) return line.slice(2).trim();\n }\n return undefined;\n}\n\n/** The `**Goal:**` one-liner from the body, label stripped, if present. */\nfunction goalLine(bodyLines: string[]): string | undefined {\n for (const line of bodyLines) {\n const match = /^\\*\\*Goal:\\*\\*(.*)$/.exec(line.trim());\n if (match?.[1] !== undefined) {\n const goal = match[1].trim();\n if (goal.length > 0) return goal;\n }\n }\n return undefined;\n}\n\n/**\n * Parse a single ticket.md. Returns the entry (minus relativePath) when it has\n * an `id:`, or a skip reason. Title resolves frontmatter `title` → first H1 →\n * frontmatter `slug` → folder name.\n */\nfunction parseTicket(\n filePath: string,\n folder: string,\n): { ok: true; entry: Omit<TicketEntry, 'relativePath'> } | { ok: false; reason: string } {\n const content = readFileSync(filePath, 'utf8');\n const { fields, bodyStart } = parseFrontmatter(content);\n\n const id = fields.get('id');\n if (id === undefined || id.length === 0) {\n return { ok: false, reason: 'missing id: in frontmatter' };\n }\n\n const bodyLines = content.split('\\n').slice(bodyStart);\n const title = fields.get('title') ?? firstHeading(bodyLines) ?? fields.get('slug') ?? folder;\n const status = fields.get('status') ?? '—';\n const epic = fields.get('epic');\n\n return {\n ok: true,\n entry: {\n id,\n folder,\n title,\n status,\n epic,\n goal: goalLine(bodyLines),\n dependsOn: parseTicketIdList(fields.get('depends_on')),\n },\n };\n}\n\n/** Parse every ticket folder directly under `directory`, returning entries +\n * skip reasons. Folders without a ticket.md are silently ignored (not skipped).\n * `pathPrefix` is prepended to the folder for the entry's relativePath. */\nfunction readTicketFolders(\n directory: string,\n pathPrefix: string,\n): { entries: TicketEntry[]; skipped: { folder: string; reason: string }[] } {\n if (!existsSync(directory)) return { entries: [], skipped: [] };\n\n const entries: TicketEntry[] = [];\n const skipped: { folder: string; reason: string }[] = [];\n\n const folders = readdirSync(directory, { withFileTypes: true })\n .filter(dirent => dirent.isDirectory() && !SKIP_DIRECTORIES.has(dirent.name))\n .map(dirent => dirent.name)\n .toSorted((a, b) => a.localeCompare(b));\n\n for (const folder of folders) {\n const ticketPath = nodePath.join(directory, folder, 'ticket.md');\n if (!existsSync(ticketPath)) continue; // not a ticket folder — ignore\n const parsed = parseTicket(ticketPath, folder);\n if (parsed.ok) {\n entries.push({ ...parsed.entry, relativePath: `${pathPrefix}/${folder}` });\n } else {\n skipped.push({ folder, reason: parsed.reason });\n }\n }\n\n return { entries, skipped };\n}\n\n/**\n * Read the corpus into active (top-level) and completed (`completed/`) entries,\n * each sorted by id, plus any skipped folders. INDEX*.md are files, so the\n * directory filter excludes them from being parsed as tickets.\n */\nexport function readTickets(ticketsDirectory: string): {\n active: TicketEntry[];\n completed: TicketEntry[];\n skipped: { folder: string; reason: string }[];\n} {\n const active = readTicketFolders(ticketsDirectory, TICKETS_RELATIVE_PATH);\n const completed = readTicketFolders(\n nodePath.join(ticketsDirectory, COMPLETED_DIRNAME),\n `${TICKETS_RELATIVE_PATH}/${COMPLETED_DIRNAME}`,\n );\n\n const byId = (a: TicketEntry, b: TicketEntry) => a.id.localeCompare(b.id);\n return {\n active: active.entries.toSorted(byId),\n completed: completed.entries.toSorted(byId),\n skipped: [...active.skipped, ...completed.skipped],\n };\n}\n\n/** Render a list of related ticket ids slug-first, falling back to the bare id\n * for targets outside this index (cross-variant or not-yet-created). */\nfunction renderRelation(ids: string[], labelById: Map<string, string>): string {\n return ids\n .map(id => {\n const title = labelById.get(id);\n return title === undefined ? id : formatTicketReference(id, title);\n })\n .join(', ');\n}\n\n/** Render one entry as a block: header, optional goal, relation edges, path. */\nfunction renderEntry(\n entry: TicketEntry,\n blocks: Map<string, string[]>,\n labelById: Map<string, string>,\n): string[] {\n const epic = entry.epic ?? '—';\n const lines = [\n `- **${formatTicketReference(entry.id, entry.title)}** (${entry.status}, epic: ${epic})`,\n ];\n if (entry.goal !== undefined) lines.push(` ${entry.goal}`);\n if (entry.dependsOn.length > 0) {\n lines.push(` blocked by: ${renderRelation(entry.dependsOn, labelById)}`);\n }\n const blocking = blocks.get(entry.id) ?? [];\n if (blocking.length > 0) lines.push(` blocks: ${renderRelation(blocking, labelById)}`);\n lines.push(` → \\`${entry.relativePath}\\``);\n return lines;\n}\n\n/** Group entries by epic; \"(no epic)\" sorts last, every other group alphabetical. */\nfunction groupByEpic(entries: TicketEntry[]): [string, TicketEntry[]][] {\n const groups = new Map<string, TicketEntry[]>();\n for (const entry of entries) {\n const key = entry.epic ?? NO_EPIC_GROUP;\n const bucket = groups.get(key);\n if (bucket) bucket.push(entry);\n else groups.set(key, [entry]);\n }\n return [...groups.entries()].toSorted(([a], [b]) => {\n if (a === NO_EPIC_GROUP) return 1;\n if (b === NO_EPIC_GROUP) return -1;\n return a.localeCompare(b);\n });\n}\n\n/**\n * Render the full index for one variant. Deterministic: same entries → same\n * bytes. No size cap — agents Read or grep the file.\n */\nexport function buildIndexContent(\n entries: TicketEntry[],\n options: { variant: 'active' | 'completed' },\n): string {\n const isActive = options.variant === 'active';\n const header = [\n isActive ? '# Project Tickets — Index' : '# Project Tickets — Completed Archive',\n '',\n '<!-- Auto-generated by `safeword sync-tickets`. Do not edit by hand. -->',\n isActive\n ? '<!-- Active tickets, grouped by epic. Completed tickets live in INDEX-completed.md. -->'\n : '<!-- Completed tickets (the completed/ archive), grouped by epic. -->',\n '',\n ];\n\n if (entries.length === 0) {\n return [...header, isActive ? 'No active tickets.' : 'No completed tickets.', ''].join('\\n');\n }\n\n const blocks = deriveBlocks(entries);\n const labelById = new Map(entries.map(entry => [entry.id, entry.title]));\n\n const lines = [...header, `## Tickets (${entries.length})`, ''];\n for (const [epic, group] of groupByEpic(entries)) {\n lines.push(`### ${epic}`, '');\n for (const entry of group) lines.push(...renderEntry(entry, blocks, labelById));\n lines.push('');\n }\n return lines.join('\\n');\n}\n\n/** Write `content` to `path` only when it differs; report whether it wrote. */\nfunction writeIfChanged(path: string, content: string): boolean {\n const previous = existsSync(path) ? readFileSync(path, 'utf8') : undefined;\n if (previous === content) return false;\n writeFileSync(path, content);\n return true;\n}\n\n/**\n * Generate/update both ticket indexes from the corpus. No-op (creates nothing)\n * when the tickets directory is absent. The completed archive is written when\n * a `completed/` directory exists or completed entries are present.\n */\nexport function syncTickets(cwd: string): TicketSyncResult {\n const ticketsDirectory = nodePath.join(cwd, TICKETS_RELATIVE_PATH);\n const indexPath = nodePath.join(ticketsDirectory, INDEX_FILENAME);\n const completedIndexPath = nodePath.join(ticketsDirectory, COMPLETED_INDEX_FILENAME);\n\n if (!existsSync(ticketsDirectory)) {\n return { wrote: false, active: [], completed: [], skipped: [], indexPath, completedIndexPath };\n }\n\n const { active, completed, skipped } = readTickets(ticketsDirectory);\n\n const wroteActive = writeIfChanged(indexPath, buildIndexContent(active, { variant: 'active' }));\n\n const completedDirectory = nodePath.join(ticketsDirectory, COMPLETED_DIRNAME);\n const wroteCompleted =\n completed.length > 0 || existsSync(completedDirectory)\n ? writeIfChanged(completedIndexPath, buildIndexContent(completed, { variant: 'completed' }))\n : false;\n\n return {\n wrote: wroteActive || wroteCompleted,\n active,\n completed,\n skipped,\n indexPath,\n completedIndexPath,\n };\n}\n"],"mappings":";;;;;AAqBO,SAAS,kBAAkB,KAAwB;AACxD,MAAI,QAAQ,OAAW,QAAO,CAAC;AAC/B,QAAM,QAAQ,IAAI,KAAK,EAAE,QAAQ,OAAO,EAAE,EAAE,QAAQ,OAAO,EAAE;AAC7D,SAAO,MACJ,MAAM,GAAG,EACT,IAAI,QAAM,GAAG,KAAK,CAAC,EACnB,OAAO,QAAM,GAAG,SAAS,CAAC;AAC/B;AAQO,SAAS,aAAa,OAA4C;AACvE,QAAM,SAAS,oBAAI,IAAsB;AACzC,aAAW,QAAQ,OAAO;AACxB,eAAW,UAAU,KAAK,WAAW;AACnC,YAAM,WAAW,OAAO,IAAI,MAAM,KAAK,CAAC;AACxC,eAAS,KAAK,KAAK,EAAE;AACrB,aAAO,IAAI,QAAQ,QAAQ;AAAA,IAC7B;AAAA,EACF;AACA,SAAO;AACT;AAQO,SAAS,yBAAyB,OAA0D;AACjG,QAAM,QAAQ,IAAI,IAAI,MAAM,IAAI,UAAQ,KAAK,EAAE,CAAC;AAChD,QAAM,WAAgD,CAAC;AACvD,aAAW,QAAQ,OAAO;AACxB,eAAW,UAAU,KAAK,WAAW;AACnC,UAAI,CAAC,MAAM,IAAI,MAAM,EAAG,UAAS,KAAK,EAAE,MAAM,KAAK,IAAI,SAAS,OAAO,CAAC;AAAA,IAC1E;AAAA,EACF;AACA,SAAO,SAAS;AAAA,IACd,CAAC,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI,KAAK,EAAE,QAAQ,cAAc,EAAE,OAAO;AAAA,EAC7E;AACF;AAQO,SAAS,oBAAoB,OAA+B;AACjE,QAAM,QAAQ,IAAI,IAAI,MAAM,IAAI,UAAQ,CAAC,KAAK,IAAI,KAAK,SAAS,CAAC,CAAC;AAClE,QAAM,UAAU,oBAAI,IAAY;AAEhC,aAAW,SAAS,MAAM,KAAK,GAAG;AAEhC,UAAM,QAAQ,CAAC,GAAI,MAAM,IAAI,KAAK,KAAK,CAAC,CAAE;AAC1C,UAAM,OAAO,oBAAI,IAAY;AAC7B,WAAO,MAAM,SAAS,GAAG;AACvB,YAAM,OAAO,MAAM,IAAI;AACvB,UAAI,SAAS,OAAW;AACxB,UAAI,SAAS,OAAO;AAClB,gBAAQ,IAAI,KAAK;AACjB;AAAA,MACF;AACA,UAAI,KAAK,IAAI,IAAI,EAAG;AACpB,WAAK,IAAI,IAAI;AACb,YAAM,KAAK,GAAI,MAAM,IAAI,IAAI,KAAK,CAAC,CAAE;AAAA,IACvC;AAAA,EACF;AAEA,SAAO,CAAC,GAAG,OAAO,EAAE,SAAS,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAC3D;;;AClFA,SAAS,YAAY,aAAa,cAAc,qBAAqB;AACrE,OAAO,cAAc;AAKd,IAAM,wBAAwB;AAC9B,IAAM,iBAAiB;AACvB,IAAM,2BAA2B;AACjC,IAAM,oBAAoB;AAEjC,IAAM,gBAAgB;AACtB,IAAM,mBAAmB,oBAAI,IAAI,CAAC,mBAAmB,KAAK,CAAC;AAuB3D,SAAS,YAAY,OAAuB;AAC1C,MACE,MAAM,UAAU,MACd,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,KAC1C,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,IAC9C;AACA,WAAO,MAAM,MAAM,GAAG,EAAE;AAAA,EAC1B;AACA,SAAO;AACT;AAGA,SAAS,iBAAiB,SAAqE;AAC7F,QAAM,QAAQ,QAAQ,MAAM,IAAI;AAChC,QAAM,SAAS,oBAAI,IAAoB;AACvC,MAAI,MAAM,CAAC,GAAG,KAAK,MAAM,MAAO,QAAO,EAAE,QAAQ,WAAW,EAAE;AAE9D,WAAS,QAAQ,GAAG,QAAQ,MAAM,QAAQ,SAAS,GAAG;AACpD,UAAM,OAAO,MAAM,KAAK,KAAK;AAC7B,QAAI,KAAK,KAAK,MAAM,MAAO,QAAO,EAAE,QAAQ,WAAW,QAAQ,EAAE;AACjE,UAAM,QAAQ,yBAAyB,KAAK,IAAI;AAChD,QAAI,QAAQ,CAAC,MAAM,OAAW,QAAO,IAAI,MAAM,CAAC,GAAG,aAAa,MAAM,CAAC,KAAK,IAAI,KAAK,CAAC,CAAC;AAAA,EACzF;AACA,SAAO,EAAE,QAAQ,WAAW,EAAE;AAChC;AAGA,SAAS,aAAa,WAAyC;AAC7D,aAAW,QAAQ,WAAW;AAC5B,QAAI,KAAK,WAAW,IAAI,EAAG,QAAO,KAAK,MAAM,CAAC,EAAE,KAAK;AAAA,EACvD;AACA,SAAO;AACT;AAGA,SAAS,SAAS,WAAyC;AACzD,aAAW,QAAQ,WAAW;AAC5B,UAAM,QAAQ,sBAAsB,KAAK,KAAK,KAAK,CAAC;AACpD,QAAI,QAAQ,CAAC,MAAM,QAAW;AAC5B,YAAM,OAAO,MAAM,CAAC,EAAE,KAAK;AAC3B,UAAI,KAAK,SAAS,EAAG,QAAO;AAAA,IAC9B;AAAA,EACF;AACA,SAAO;AACT;AAOA,SAAS,YACP,UACA,QACwF;AACxF,QAAM,UAAU,aAAa,UAAU,MAAM;AAC7C,QAAM,EAAE,QAAQ,UAAU,IAAI,iBAAiB,OAAO;AAEtD,QAAM,KAAK,OAAO,IAAI,IAAI;AAC1B,MAAI,OAAO,UAAa,GAAG,WAAW,GAAG;AACvC,WAAO,EAAE,IAAI,OAAO,QAAQ,6BAA6B;AAAA,EAC3D;AAEA,QAAM,YAAY,QAAQ,MAAM,IAAI,EAAE,MAAM,SAAS;AACrD,QAAM,QAAQ,OAAO,IAAI,OAAO,KAAK,aAAa,SAAS,KAAK,OAAO,IAAI,MAAM,KAAK;AACtF,QAAM,SAAS,OAAO,IAAI,QAAQ,KAAK;AACvC,QAAM,OAAO,OAAO,IAAI,MAAM;AAE9B,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,OAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,MAAM,SAAS,SAAS;AAAA,MACxB,WAAW,kBAAkB,OAAO,IAAI,YAAY,CAAC;AAAA,IACvD;AAAA,EACF;AACF;AAKA,SAAS,kBACP,WACA,YAC2E;AAC3E,MAAI,CAAC,WAAW,SAAS,EAAG,QAAO,EAAE,SAAS,CAAC,GAAG,SAAS,CAAC,EAAE;AAE9D,QAAM,UAAyB,CAAC;AAChC,QAAM,UAAgD,CAAC;AAEvD,QAAM,UAAU,YAAY,WAAW,EAAE,eAAe,KAAK,CAAC,EAC3D,OAAO,YAAU,OAAO,YAAY,KAAK,CAAC,iBAAiB,IAAI,OAAO,IAAI,CAAC,EAC3E,IAAI,YAAU,OAAO,IAAI,EACzB,SAAS,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAExC,aAAW,UAAU,SAAS;AAC5B,UAAM,aAAa,SAAS,KAAK,WAAW,QAAQ,WAAW;AAC/D,QAAI,CAAC,WAAW,UAAU,EAAG;AAC7B,UAAM,SAAS,YAAY,YAAY,MAAM;AAC7C,QAAI,OAAO,IAAI;AACb,cAAQ,KAAK,EAAE,GAAG,OAAO,OAAO,cAAc,GAAG,UAAU,IAAI,MAAM,GAAG,CAAC;AAAA,IAC3E,OAAO;AACL,cAAQ,KAAK,EAAE,QAAQ,QAAQ,OAAO,OAAO,CAAC;AAAA,IAChD;AAAA,EACF;AAEA,SAAO,EAAE,SAAS,QAAQ;AAC5B;AAOO,SAAS,YAAY,kBAI1B;AACA,QAAM,SAAS,kBAAkB,kBAAkB,qBAAqB;AACxE,QAAM,YAAY;AAAA,IAChB,SAAS,KAAK,kBAAkB,iBAAiB;AAAA,IACjD,GAAG,qBAAqB,IAAI,iBAAiB;AAAA,EAC/C;AAEA,QAAM,OAAO,CAAC,GAAgB,MAAmB,EAAE,GAAG,cAAc,EAAE,EAAE;AACxE,SAAO;AAAA,IACL,QAAQ,OAAO,QAAQ,SAAS,IAAI;AAAA,IACpC,WAAW,UAAU,QAAQ,SAAS,IAAI;AAAA,IAC1C,SAAS,CAAC,GAAG,OAAO,SAAS,GAAG,UAAU,OAAO;AAAA,EACnD;AACF;AAIA,SAAS,eAAe,KAAe,WAAwC;AAC7E,SAAO,IACJ,IAAI,QAAM;AACT,UAAM,QAAQ,UAAU,IAAI,EAAE;AAC9B,WAAO,UAAU,SAAY,KAAK,sBAAsB,IAAI,KAAK;AAAA,EACnE,CAAC,EACA,KAAK,IAAI;AACd;AAGA,SAAS,YACP,OACA,QACA,WACU;AACV,QAAM,OAAO,MAAM,QAAQ;AAC3B,QAAM,QAAQ;AAAA,IACZ,OAAO,sBAAsB,MAAM,IAAI,MAAM,KAAK,CAAC,OAAO,MAAM,MAAM,WAAW,IAAI;AAAA,EACvF;AACA,MAAI,MAAM,SAAS,OAAW,OAAM,KAAK,KAAK,MAAM,IAAI,EAAE;AAC1D,MAAI,MAAM,UAAU,SAAS,GAAG;AAC9B,UAAM,KAAK,iBAAiB,eAAe,MAAM,WAAW,SAAS,CAAC,EAAE;AAAA,EAC1E;AACA,QAAM,WAAW,OAAO,IAAI,MAAM,EAAE,KAAK,CAAC;AAC1C,MAAI,SAAS,SAAS,EAAG,OAAM,KAAK,aAAa,eAAe,UAAU,SAAS,CAAC,EAAE;AACtF,QAAM,KAAK,cAAS,MAAM,YAAY,IAAI;AAC1C,SAAO;AACT;AAGA,SAAS,YAAY,SAAmD;AACtE,QAAM,SAAS,oBAAI,IAA2B;AAC9C,aAAW,SAAS,SAAS;AAC3B,UAAM,MAAM,MAAM,QAAQ;AAC1B,UAAM,SAAS,OAAO,IAAI,GAAG;AAC7B,QAAI,OAAQ,QAAO,KAAK,KAAK;AAAA,QACxB,QAAO,IAAI,KAAK,CAAC,KAAK,CAAC;AAAA,EAC9B;AACA,SAAO,CAAC,GAAG,OAAO,QAAQ,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM;AAClD,QAAI,MAAM,cAAe,QAAO;AAChC,QAAI,MAAM,cAAe,QAAO;AAChC,WAAO,EAAE,cAAc,CAAC;AAAA,EAC1B,CAAC;AACH;AAMO,SAAS,kBACd,SACA,SACQ;AACR,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,SAAS;AAAA,IACb,WAAW,mCAA8B;AAAA,IACzC;AAAA,IACA;AAAA,IACA,WACI,4FACA;AAAA,IACJ;AAAA,EACF;AAEA,MAAI,QAAQ,WAAW,GAAG;AACxB,WAAO,CAAC,GAAG,QAAQ,WAAW,uBAAuB,yBAAyB,EAAE,EAAE,KAAK,IAAI;AAAA,EAC7F;AAEA,QAAM,SAAS,aAAa,OAAO;AACnC,QAAM,YAAY,IAAI,IAAI,QAAQ,IAAI,WAAS,CAAC,MAAM,IAAI,MAAM,KAAK,CAAC,CAAC;AAEvE,QAAM,QAAQ,CAAC,GAAG,QAAQ,eAAe,QAAQ,MAAM,KAAK,EAAE;AAC9D,aAAW,CAAC,MAAM,KAAK,KAAK,YAAY,OAAO,GAAG;AAChD,UAAM,KAAK,OAAO,IAAI,IAAI,EAAE;AAC5B,eAAW,SAAS,MAAO,OAAM,KAAK,GAAG,YAAY,OAAO,QAAQ,SAAS,CAAC;AAC9E,UAAM,KAAK,EAAE;AAAA,EACf;AACA,SAAO,MAAM,KAAK,IAAI;AACxB;AAGA,SAAS,eAAe,MAAc,SAA0B;AAC9D,QAAM,WAAW,WAAW,IAAI,IAAI,aAAa,MAAM,MAAM,IAAI;AACjE,MAAI,aAAa,QAAS,QAAO;AACjC,gBAAc,MAAM,OAAO;AAC3B,SAAO;AACT;AAOO,SAAS,YAAY,KAA+B;AACzD,QAAM,mBAAmB,SAAS,KAAK,KAAK,qBAAqB;AACjE,QAAM,YAAY,SAAS,KAAK,kBAAkB,cAAc;AAChE,QAAM,qBAAqB,SAAS,KAAK,kBAAkB,wBAAwB;AAEnF,MAAI,CAAC,WAAW,gBAAgB,GAAG;AACjC,WAAO,EAAE,OAAO,OAAO,QAAQ,CAAC,GAAG,WAAW,CAAC,GAAG,SAAS,CAAC,GAAG,WAAW,mBAAmB;AAAA,EAC/F;AAEA,QAAM,EAAE,QAAQ,WAAW,QAAQ,IAAI,YAAY,gBAAgB;AAEnE,QAAM,cAAc,eAAe,WAAW,kBAAkB,QAAQ,EAAE,SAAS,SAAS,CAAC,CAAC;AAE9F,QAAM,qBAAqB,SAAS,KAAK,kBAAkB,iBAAiB;AAC5E,QAAM,iBACJ,UAAU,SAAS,KAAK,WAAW,kBAAkB,IACjD,eAAe,oBAAoB,kBAAkB,WAAW,EAAE,SAAS,YAAY,CAAC,CAAC,IACzF;AAEN,SAAO;AAAA,IACL,OAAO,eAAe;AAAA,IACtB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":[]}
@@ -3,7 +3,7 @@ import {
3
3
  addInstalledPack,
4
4
  isGitRepo,
5
5
  isPackInstalled
6
- } from "./chunk-FL2WCXYR.js";
6
+ } from "./chunk-ZLEHZR4V.js";
7
7
  import {
8
8
  SAFEWORD_PEER_DEPENDENCIES
9
9
  } from "./chunk-HSC7TELY.js";
@@ -14,7 +14,7 @@ import {
14
14
  import {
15
15
  info,
16
16
  listItem
17
- } from "./chunk-VZ2E2QRM.js";
17
+ } from "./chunk-46XXWC64.js";
18
18
 
19
19
  // src/packs/install.ts
20
20
  function installPack(packId, cwd) {
@@ -309,4 +309,4 @@ export {
309
309
  getEslintPeerMismatchWarning,
310
310
  maybeAutoPatchOrNudge
311
311
  };
312
- //# sourceMappingURL=chunk-HDMKYYSJ.js.map
312
+ //# sourceMappingURL=chunk-KWD4OQL4.js.map
@@ -0,0 +1,9 @@
1
+ // src/utils/ticket-reference.ts
2
+ function formatTicketReference(id, label) {
3
+ return label ? `${label} (${id})` : id;
4
+ }
5
+
6
+ export {
7
+ formatTicketReference
8
+ };
9
+ //# sourceMappingURL=chunk-NHXVS5FL.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/utils/ticket-reference.ts"],"sourcesContent":["/**\n * Slug-first ticket-reference rendering (ticket ZRXM6Q).\n *\n * Every surface that names a ticket should lead with a human label — the slug\n * or the title, whichever the call site has — and trail the Crockford ID as a\n * locator: `embed-figure-it-out (ZBVGPF)`, never bare `ZBVGPF`. The label lets\n * a human or agent *recognize* the ticket (NN/g recognition-over-recall); the\n * ID stays for collision-free lookup across parallel sessions and git branches.\n */\n\n/**\n * Format a ticket reference human-first: `label (ID)`, or the bare ID when no\n * label is known.\n * @param id the ticket's Crockford Base32 ID\n * @param label the ticket's human-readable slug or title, if available\n */\nexport function formatTicketReference(id: string, label?: string): string {\n return label ? `${label} (${id})` : id;\n}\n"],"mappings":";AAgBO,SAAS,sBAAsB,IAAY,OAAwB;AACxE,SAAO,QAAQ,GAAG,KAAK,KAAK,EAAE,MAAM;AACtC;","names":[]}