kataster 0.3.1 → 0.6.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 (89) hide show
  1. package/.claude/THIRD_PARTY_NOTICES.md +4 -0
  2. package/.claude/agents/acceptance-tester.md +7 -2
  3. package/.claude/agents/code-reviewer.md +7 -2
  4. package/.claude/agents/domain-architect.md +7 -3
  5. package/.claude/agents/researcher.md +77 -0
  6. package/.claude/agents/ux-designer.md +42 -47
  7. package/.claude/hooks/validate-docs.ts +17 -1
  8. package/.claude/lib/architecture.ts +3 -33
  9. package/.claude/lib/backlog.ts +2 -0
  10. package/.claude/lib/config.ts +49 -0
  11. package/.claude/lib/discovery.ts +200 -32
  12. package/.claude/lib/engineering.ts +18 -6
  13. package/.claude/lib/gaps.ts +179 -0
  14. package/.claude/lib/glob.ts +39 -0
  15. package/.claude/lib/repo.ts +143 -19
  16. package/.claude/lib/research.ts +303 -0
  17. package/.claude/lib/slices.ts +529 -0
  18. package/.claude/package.json +1 -0
  19. package/.claude/settings.json +2 -2
  20. package/.claude/skills/adopt/SKILL.md +111 -34
  21. package/.claude/skills/adopt/scripts/gap-register.ts +61 -0
  22. package/.claude/skills/adopt/scripts/repo-map.ts +34 -2
  23. package/.claude/skills/adopt/scripts/slice-doc.ts +117 -0
  24. package/.claude/skills/adopt/scripts/slice.ts +163 -0
  25. package/.claude/skills/adopt/templates/context-map.as-is.md +22 -0
  26. package/.claude/skills/architect/SKILL.md +6 -1
  27. package/.claude/skills/backlog/SKILL.md +2 -2
  28. package/.claude/skills/bootstrap/SKILL.md +9 -7
  29. package/.claude/skills/domain-model/CONTEXT-FORMAT.md +1 -1
  30. package/.claude/skills/domain-model/SKILL.md +24 -4
  31. package/.claude/skills/event-storming/SKILL.md +3 -2
  32. package/.claude/skills/event-storming/schema/events.schema.json +2 -0
  33. package/.claude/skills/event-storming/templates/events.example.yaml +6 -0
  34. package/.claude/skills/feature/SKILL.md +16 -13
  35. package/.claude/skills/feature/scripts/intake.ts +1 -1
  36. package/.claude/skills/grilling/SKILL.md +15 -0
  37. package/.claude/skills/groom-backlog/SKILL.md +19 -17
  38. package/.claude/skills/implement/SKILL.md +199 -78
  39. package/.claude/skills/implement/scripts/characterize.ts +252 -0
  40. package/.claude/skills/implement/scripts/check-scenarios.ts +1 -1
  41. package/.claude/skills/implement/scripts/story-context.ts +65 -1
  42. package/.claude/skills/implement/scripts/verify.ts +4 -1
  43. package/.claude/skills/improve-codebase-architecture/SKILL.md +4 -1
  44. package/.claude/skills/prototype/SKILL.md +1 -1
  45. package/.claude/skills/research/SKILL.md +149 -0
  46. package/.claude/skills/research/scripts/research-check.ts +105 -0
  47. package/.claude/skills/research/templates/research.md +29 -0
  48. package/.claude/skills/review-stories/SKILL.md +49 -19
  49. package/.claude/skills/shape/SKILL.md +26 -7
  50. package/.claude/skills/tdd/SKILL.md +10 -0
  51. package/.claude/skills/user-stories/SKILL.md +128 -51
  52. package/.claude/skills/user-stories/schema/story.schema.json +10 -3
  53. package/.claude/skills/user-stories/scripts/lint-story.ts +56 -0
  54. package/.claude/skills/user-stories/templates/story-template.md +6 -2
  55. package/.claude/skills/ux-design/SKILL.md +9 -7
  56. package/.opencode/agents/acceptance-tester.md +8 -3
  57. package/.opencode/agents/code-reviewer.md +8 -3
  58. package/.opencode/agents/domain-architect.md +7 -3
  59. package/.opencode/agents/researcher.md +91 -0
  60. package/.opencode/agents/ux-designer.md +42 -52
  61. package/.opencode/commands/adopt.md +1 -1
  62. package/.opencode/commands/bootstrap.md +1 -1
  63. package/.opencode/commands/feature.md +1 -1
  64. package/.opencode/commands/groom-backlog.md +1 -1
  65. package/.opencode/commands/implement.md +1 -1
  66. package/.opencode/commands/research.md +7 -0
  67. package/.opencode/commands/review-stories.md +1 -1
  68. package/.opencode/commands/shape.md +1 -1
  69. package/AGENTS.md +30 -0
  70. package/CHANGELOG.md +73 -0
  71. package/README.md +108 -24
  72. package/guide/brownfield.md +41 -12
  73. package/guide/concepts.md +23 -6
  74. package/guide/greenfield.md +4 -3
  75. package/package.json +1 -1
  76. package/src/cli.mjs +42 -4
  77. package/src/detect.mjs +9 -0
  78. package/src/install.mjs +66 -6
  79. package/templates/docs/domain/context-map.md +5 -1
  80. package/templates/docs/domain/events.yaml +3 -0
  81. package/templates/docs/research/README.md +70 -0
  82. package/templates/docs/stories/README.md +12 -3
  83. package/templates/docs/workflow.md +76 -19
  84. package/.claude/agents/lead-dev.md +0 -52
  85. package/.claude/agents/product-owner.md +0 -39
  86. package/.claude/agents/requirements-engineer.md +0 -44
  87. package/.opencode/agents/lead-dev.md +0 -69
  88. package/.opencode/agents/product-owner.md +0 -59
  89. package/.opencode/agents/requirements-engineer.md +0 -58
@@ -18,6 +18,7 @@ import { execFileSync } from "node:child_process";
18
18
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
19
19
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
20
20
  import { graphLabel, graphOrigin, graphToolLabel, queryToolGraph, type ToolGraph } from "./toolgraph.ts";
21
+ import { classifyPackageRoles, clusterSlices, sourceFiles, type PackageRole, type SliceSuggestion } from "./slices.ts";
21
22
 
22
23
  export const ROOT = resolve(import.meta.dirname, "..", "..");
23
24
  export const REPO_MAP_JSON = join("docs", "architecture", "repo-map.json");
@@ -51,6 +52,9 @@ export interface RepoPackage {
51
52
  hasTests: boolean;
52
53
  adopted: boolean;
53
54
  adoptedAt?: string;
55
+ /** Domain, layer or shared infrastructure – a layer package is not a domain slice. */
56
+ role?: PackageRole;
57
+ roleReasons?: string[];
54
58
  /** Nx project name when it differs from the package name. */
55
59
  projectName?: string;
56
60
  }
@@ -66,6 +70,8 @@ export interface RepoMap {
66
70
  tooling: Tooling;
67
71
  packages: RepoPackage[];
68
72
  ranking: RankedPackage[];
73
+ /** Concept clusters by shared stem/table – a suggestion for layered repositories, not a verdict. */
74
+ slices?: SliceSuggestion[];
69
75
  /** Optional enrichment from the build tool; the manifest graph stays the source of truth. */
70
76
  toolGraph?: ToolGraph;
71
77
  }
@@ -73,6 +79,7 @@ export interface RepoMap {
73
79
  export interface SliceProposal {
74
80
  domain?: RepoPackage;
75
81
  library?: RepoPackage;
82
+ cluster?: SliceSuggestion;
76
83
  }
77
84
 
78
85
  type Json = Record<string, unknown>;
@@ -612,8 +619,20 @@ export function scanRepo(root: string, options: { toolGraph?: boolean } = {}): R
612
619
  for (const pkg of packages) pkg.consumers.sort();
613
620
  packages.sort((a, b) => a.path.localeCompare(b.path));
614
621
 
622
+ // A layered repository needs concept slices: generic packages such as services/ or utils/ host many concepts
623
+ // and must not be proposed as a domain. The scan is shared by the role assignment and the clustering.
624
+ const files = sourceFiles(root);
625
+ const roles = classifyPackageRoles(root, packages, files);
626
+ for (const pkg of packages) {
627
+ const role = roles.get(pkg.path);
628
+ if (!role) continue;
629
+ pkg.role = role.role;
630
+ pkg.roleReasons = role.reasons;
631
+ }
632
+
615
633
  const ranking = rankPackages(root, packages);
616
- const map: RepoMap = { version: 1, tooling, packages, ranking };
634
+ const slices = clusterSlices(root, packages, files);
635
+ const map: RepoMap = { version: 1, tooling, packages, ranking, slices };
617
636
  // Enrichment only. The manifest graph remains the source of truth, so the map stays usable without the
618
637
  // build tool: Turbo exposes a task graph, Nx a project graph, Bazel its BUILD deps.
619
638
  if (options.toolGraph !== false && tooling.kind !== "none") {
@@ -654,7 +673,9 @@ export function rankPackages(root: string, packages: RepoPackage[]): RankedPacka
654
673
  return packages
655
674
  .map((pkg) => {
656
675
  const changes = churn.get(pkg.name) ?? 0;
657
- const score = 0.55 * (changes / maxChurn) + 0.3 * (pkg.consumers.length / maxConsumers) + 0.15 * (pkg.kind === "library" ? 0.4 : 1);
676
+ const base = 0.55 * (changes / maxChurn) + 0.3 * (pkg.consumers.length / maxConsumers) + 0.15 * (pkg.kind === "library" ? 0.4 : 1);
677
+ // A layer package can have churn and fan-in, but it is not a domain: keep it visible, rank it low.
678
+ const score = pkg.role === "layer" ? base * 0.15 : base;
658
679
  const reasons: string[] = [];
659
680
  if (changes) reasons.push(`${changes} changed file${changes === 1 ? "" : "s"} in recent history`);
660
681
  if (pkg.consumers.length) {
@@ -662,6 +683,8 @@ export function rankPackages(root: string, packages: RepoPackage[]): RankedPacka
662
683
  reasons.push(`${n} package${n === 1 ? "" : "s"} ${n === 1 ? "depends" : "depend"} on it`);
663
684
  }
664
685
  if (pkg.kind !== "package") reasons.push(pkg.kind === "library" ? "shared library" : pkg.kind);
686
+ if (pkg.role === "layer") reasons.push("layer package – hosts many concepts, not a domain slice");
687
+ if (pkg.role === "shared") reasons.push("shared infrastructure – not a domain slice");
665
688
  if (!reasons.length) reasons.push("no churn or consumers recorded");
666
689
  return { path: pkg.path, score: Math.round(score * 100) / 100, reasons };
667
690
  })
@@ -671,15 +694,21 @@ export function rankPackages(root: string, packages: RepoPackage[]): RankedPacka
671
694
  export function proposeSlices(map: RepoMap): SliceProposal {
672
695
  const byPath = new Map(map.packages.map((p) => [p.path, p]));
673
696
  const ranked = map.ranking.map((r) => byPath.get(r.path)).filter((p): p is RepoPackage => p !== undefined);
674
- const domain = ranked.find((p) => p.kind === "app" || p.kind === "service") ?? ranked.find((p) => p.kind !== "library") ?? ranked[0];
675
- if (!domain) return {};
697
+ const domainCandidates = ranked.filter((p) => p.role !== "layer");
698
+ const domain =
699
+ domainCandidates.find((p) => p.kind === "app" || p.kind === "service") ??
700
+ domainCandidates.find((p) => p.kind !== "library") ??
701
+ domainCandidates[0];
702
+
703
+ const cluster = map.slices?.find((slice) => !slice.adopted);
704
+ if (!domain) return cluster ? { cluster } : {};
676
705
 
677
706
  const libs = domain.internalDeps
678
707
  .map((name) => map.packages.find((p) => p.name === name))
679
708
  .filter((p): p is RepoPackage => p !== undefined && p.kind === "library")
680
709
  .sort((a, b) => b.consumers.length - a.consumers.length);
681
710
  const library = libs[0] ?? ranked.find((p) => p.kind === "library" && p.name !== domain.name);
682
- return { domain, library };
711
+ return { domain, library, cluster };
683
712
  }
684
713
 
685
714
  // --------------------------------------------------------------------------
@@ -806,13 +835,13 @@ export function renderRepoMapMarkdown(map: RepoMap): string {
806
835
  "",
807
836
  "## Packages",
808
837
  "",
809
- "| Package | Path | Kind | Language | Depends on | Used by | Adopted |",
810
- "|---|---|---|---|---|---|---|",
838
+ "| Package | Path | Kind | Role | Language | Depends on | Used by | Adopted |",
839
+ "|---|---|---|---|---|---|---|---|",
811
840
  ];
812
841
 
813
842
  for (const pkg of [...map.packages].sort((a, b) => a.path.localeCompare(b.path))) {
814
843
  out.push(
815
- `| ${tableCell(pkg.name)} | \`${pkg.path}\` | ${pkg.kind} | ${pkg.language} | ` +
844
+ `| ${tableCell(pkg.name)} | \`${pkg.path}\` | ${pkg.kind} | ${pkg.role ?? "–"} | ${pkg.language} | ` +
816
845
  `${pkg.internalDeps.length ? pkg.internalDeps.map((d) => `\`${d}\``).join(", ") : "–"} | ` +
817
846
  `${pkg.consumers.length ? pkg.consumers.map((d) => `\`${d}\``).join(", ") : "–"} | ` +
818
847
  `${pkg.adopted ? "yes" : "–"} |`,
@@ -820,6 +849,50 @@ export function renderRepoMapMarkdown(map: RepoMap): string {
820
849
  }
821
850
  out.push("");
822
851
 
852
+ const layers = map.packages.filter((pkg) => pkg.role === "layer");
853
+ if (layers.length) {
854
+ out.push(
855
+ "## Layer packages",
856
+ "",
857
+ "These packages are layers of the application, not domain slices. Their concepts cut across them.",
858
+ "",
859
+ "| Package | Path | Why |",
860
+ "|---|---|---|",
861
+ );
862
+ for (const pkg of layers) {
863
+ out.push(`| ${tableCell(pkg.name)} | \`${pkg.path}\` | ${tableCell((pkg.roleReasons ?? []).join("; "))} |`);
864
+ }
865
+ out.push("");
866
+ }
867
+
868
+ if (map.slices?.length) {
869
+ out.push(
870
+ "## Slice suggestions (shared concepts)",
871
+ "",
872
+ "Files that share a concept stem or a table across layers. These are suggestions – confirm the scope with the user.",
873
+ "",
874
+ "| Concept | Layers | Files | Tables | Why | Adopted |",
875
+ "|---|---|---|---|---|---|",
876
+ );
877
+ for (const slice of map.slices) {
878
+ out.push(
879
+ `| ${slice.name} | ${slice.layers.join(", ") || "–"} | ${slice.files.length} | ` +
880
+ `${slice.tables.map((t) => `\`${t}\``).join(", ") || "–"} | ${tableCell(slice.reasons.join("; "))} | ` +
881
+ `${slice.adopted ? "yes" : "–"} |`,
882
+ );
883
+ }
884
+ out.push("");
885
+ out.push(
886
+ "Adopt a concept slice with:",
887
+ "",
888
+ "```bash",
889
+ "node .claude/skills/adopt/scripts/slice.ts --cluster <name>",
890
+ "node .claude/skills/adopt/scripts/repo-map.ts --adopt-slice <name>",
891
+ "```",
892
+ "",
893
+ );
894
+ }
895
+
823
896
  const edges = map.packages.flatMap((pkg) => pkg.internalDeps.map((dep) => [pkg, dep] as const));
824
897
  if (edges.length) {
825
898
  const nodes = new Map<string, RepoPackage>();
@@ -871,12 +944,23 @@ export function renderRepoMapMarkdown(map: RepoMap): string {
871
944
  });
872
945
  out.push("");
873
946
 
874
- const { domain, library } = proposeSlices(map);
875
- if (domain) {
876
- const label = domain.path === "." ? "root package" : `\`${domain.path}\``;
877
- out.push("## Suggested first slice", "", `- **Domain:** ${label} (${domain.name})`);
878
- if (library) out.push(`- **Shared library:** \`${library.path}\` (${library.name})`);
879
- out.push("", "Adopt one domain and at most one shared library per session. Then mark the slice adopted:", "", "```bash", `node .claude/skills/adopt/scripts/repo-map.ts --adopt ${domain.path}`, "```", "");
947
+ const { domain, library, cluster } = proposeSlices(map);
948
+ if (domain || cluster) {
949
+ out.push("## Suggested first slice", "");
950
+ if (cluster) {
951
+ out.push(`- **Concept:** \`${cluster.name}\` – ${cluster.layers.join(", ") || "one layer"} (${cluster.files.length} file(s))`);
952
+ out.push(` Start with \`node .claude/skills/adopt/scripts/slice.ts --cluster ${cluster.name}\`.`);
953
+ }
954
+ if (domain) {
955
+ const label = domain.path === "." ? "root package" : `\`${domain.path}\``;
956
+ out.push(`- **Package:** ${label} (${domain.name})`);
957
+ if (library) out.push(`- **Shared library:** \`${library.path}\` (${library.name})`);
958
+ }
959
+ out.push("", "Adopt one concept or domain plus at most one shared library per session. Then mark it adopted:");
960
+ out.push("", "```bash");
961
+ if (cluster) out.push(`node .claude/skills/adopt/scripts/repo-map.ts --adopt-slice ${cluster.name}`);
962
+ if (domain) out.push(`node .claude/skills/adopt/scripts/repo-map.ts --adopt ${domain.path}`);
963
+ out.push("```", "");
880
964
  }
881
965
  return out.join("\n");
882
966
  }
@@ -904,15 +988,26 @@ export function rootBlock(map: RepoMap, root = resolve(import.meta.dirname, ".."
904
988
  lines.push(`- **Layout:** ${groupsList.slice(0, 8).map((g) => `\`${g.dir}/\` (${g.total})`).join(", ")}`);
905
989
  }
906
990
  lines.push("- **Detail:** `docs/architecture/repo-map.md` – package list, dependency edges, consumers, adoption ranking.");
907
- if (adopted.length) {
991
+ const openSlices = (map.slices ?? []).filter((slice) => !slice.adopted).slice(0, 5);
992
+ if (openSlices.length) {
993
+ lines.push(
994
+ `- **Concept slices:** ${openSlices.map((slice) => `\`${slice.name}\` (${slice.layers.join(", ") || "one layer"})`).join(", ")}` +
995
+ " – candidates for `/adopt`, not confirmed domains.",
996
+ );
997
+ }
998
+ const adoptedSlices = (map.slices ?? []).filter((slice) => slice.adopted);
999
+ if (adopted.length || adoptedSlices.length) {
1000
+ const labels = [
1001
+ ...adopted.map((p) => (p.path === "." ? "root package" : `\`${p.path}\``)),
1002
+ ...adoptedSlices.map((slice) => `concept \`${slice.name}\``),
1003
+ ];
908
1004
  const rest = map.packages.length - adopted.length;
909
- const labels = adopted.map((p) => (p.path === "." ? "root package" : `\`${p.path}\``));
910
1005
  lines.push(
911
1006
  `- **Adopted slices:** ${labels.join(", ")}` +
912
- (rest ? `. Everything else is **not adopted**: run \`/adopt <path>\` before inferring domain there.` : "."),
1007
+ (rest ? ". Everything else is **not adopted**: run `/adopt` before inferring domain there." : "."),
913
1008
  );
914
1009
  } else {
915
- lines.push("- **No package is adopted yet.** Run `/adopt` before inferring domain from code.");
1010
+ lines.push("- **No slice is adopted yet.** Run `/adopt` before inferring domain from code.");
916
1011
  }
917
1012
  lines.push("", "## Commands", "", "| Scope | Command |", "|---|---|");
918
1013
 
@@ -1173,12 +1268,41 @@ export function markAdopted(root: string, path: string, date = new Date().toISOS
1173
1268
  return map;
1174
1269
  }
1175
1270
 
1271
+ export function markSliceAdopted(root: string, name: string, date = new Date().toISOString().slice(0, 10)): RepoMap {
1272
+ const map = readRepoMap(root);
1273
+ if (!map) throw new Error(`no ${REPO_MAP_JSON} – run repo-map.ts first`);
1274
+ const slices = map.slices ?? (map.slices = []);
1275
+ let slice = slices.find((entry) => entry.name === name);
1276
+ if (!slice) {
1277
+ // An ad-hoc scope (glob, route, table, event) has no generated cluster; keep it as an explicit adoption.
1278
+ slice = {
1279
+ name,
1280
+ terms: [],
1281
+ tables: [],
1282
+ files: [],
1283
+ packages: [],
1284
+ layers: [],
1285
+ score: 0,
1286
+ reasons: ["marked adopted manually"],
1287
+ };
1288
+ slices.push(slice);
1289
+ }
1290
+ slice.adopted = true;
1291
+ slice.adoptedAt = date;
1292
+ writeRepoMap(root, map);
1293
+ writeRootBlock(root, map);
1294
+ return map;
1295
+ }
1296
+
1176
1297
  /** Stable view for staleness checks: generated docs and ranking may change with history. */
1177
1298
  export function stableMap(map: RepoMap): string {
1178
1299
  const packages = [...map.packages]
1179
1300
  .sort((a, b) => a.path.localeCompare(b.path))
1180
1301
  .map(({ adopted: _adopted, adoptedAt: _adoptedAt, consumers: _consumers, ...rest }) => rest);
1181
- return JSON.stringify({ version: map.version, tooling: map.tooling, packages }, null, 2);
1302
+ const slices = [...(map.slices ?? [])]
1303
+ .sort((a, b) => a.name.localeCompare(b.name))
1304
+ .map(({ adopted: _adopted, adoptedAt: _adoptedAt, ...rest }) => rest);
1305
+ return JSON.stringify({ version: map.version, tooling: map.tooling, packages, slices }, null, 2);
1182
1306
  }
1183
1307
 
1184
1308
  export function findPackage(map: RepoMap, path: string): RepoPackage | undefined {
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Research files (`docs/research/<topic>.md`) – evidence for cross-cutting decisions, not a specification.
3
+ *
4
+ * The researcher agent is read-only and returns structured findings. The main session writes them verbatim into a
5
+ * research file with frontmatter: topic, question, created, retrieved, expires and the stack versions queried.
6
+ * Expiry is computed when the file is written or refreshed (`retrieved + research.expiryMonths`), never on read.
7
+ *
8
+ * This module owns the format, the review gate and the status helpers. It does not import discovery.ts: discovery.ts
9
+ * imports this module for story warnings, and a cycle would make both unreadable.
10
+ *
11
+ * Same rules as the rest of the payload: Node >= 22.18, erasable TypeScript only, one runtime dependency (`yaml`).
12
+ */
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { basename, join, relative, resolve, sep } from "node:path";
15
+ import { parse as parseYaml } from "yaml";
16
+ import { loadResearchConfig, type Obj, type ResearchConfig } from "./config.ts";
17
+
18
+ export const ROOT = resolve(import.meta.dirname, "..", "..");
19
+ export const RESEARCH_DIR = join(ROOT, "docs", "research");
20
+
21
+ export type ResearchStatus = "valid" | "expired" | "missing" | "invalid";
22
+
23
+ /** Today's date in UTC, `YYYY-MM-DD`. */
24
+ export function today(): string {
25
+ return new Date().toISOString().slice(0, 10);
26
+ }
27
+
28
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
29
+ const TOPIC_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
30
+
31
+ function isValidDate(value: unknown): value is string {
32
+ if (typeof value !== "string" || !DATE_RE.test(value)) return false;
33
+ const date = new Date(`${value}T00:00:00Z`);
34
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
35
+ }
36
+
37
+ /** `date` plus `months`, in UTC; a month-end date is clamped to the target month's last day. */
38
+ export function addMonths(date: string, months: number): string {
39
+ const base = new Date(`${date}T00:00:00Z`);
40
+ if (Number.isNaN(base.getTime())) throw new Error(`invalid date '${date}'`);
41
+ const target = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + Math.trunc(months), 1));
42
+ const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
43
+ const day = Math.min(base.getUTCDate(), lastDay);
44
+ return `${target.toISOString().slice(0, 7)}-${String(day).padStart(2, "0")}`;
45
+ }
46
+
47
+ /** Project-relative POSIX path for display; absolute when the file is outside the project. */
48
+ export function relPath(path: string): string {
49
+ const r = relative(ROOT, path);
50
+ return r.startsWith("..") || resolve(r) === r ? path : r.split(sep).join("/");
51
+ }
52
+
53
+ /** All research files: Markdown, no README, no `_`-prefixed drafts. */
54
+ export function researchFiles(): string[] {
55
+ if (!existsSync(RESEARCH_DIR)) return [];
56
+ return readdirSync(RESEARCH_DIR)
57
+ .filter((name) => name.endsWith(".md") && name !== "README.md" && !name.startsWith("_"))
58
+ .sort()
59
+ .map((name) => join(RESEARCH_DIR, name));
60
+ }
61
+
62
+ export interface ResearchDocument {
63
+ meta: Obj;
64
+ body: string;
65
+ }
66
+
67
+ /** Parses a research file. Throws on a missing or broken frontmatter. */
68
+ export function parseResearchFile(path: string): ResearchDocument {
69
+ const text = readFileSync(path, "utf8").replaceAll("\r\n", "\n");
70
+ if (!text.startsWith("---\n")) throw new Error("YAML frontmatter is missing (file must start with '---')");
71
+ const end = text.indexOf("\n---", 4);
72
+ if (end === -1) throw new Error("frontmatter is not closed with '---'");
73
+ const meta = parseYaml(text.slice(4, end)) ?? {};
74
+ if (meta === null || typeof meta !== "object" || Array.isArray(meta)) {
75
+ throw new Error("frontmatter must be an object");
76
+ }
77
+ return { meta: meta as Obj, body: text.slice(end + 4).replace(/^\n+/, "") };
78
+ }
79
+
80
+ export interface ResearchValidation {
81
+ errors: string[];
82
+ warnings: string[];
83
+ doc: ResearchDocument | null;
84
+ }
85
+
86
+ const FINDING_FIELDS = ["Statement", "Source", "Retrieved", "Tier", "Confidence", "Domain-model impact"] as const;
87
+
88
+ /** Validates the persisted format. Expiry itself is a status, not a format error. */
89
+ export function validateResearchFile(path: string): ResearchValidation {
90
+ const errors: string[] = [];
91
+ const warnings: string[] = [];
92
+ let doc: ResearchDocument;
93
+ try {
94
+ doc = parseResearchFile(path);
95
+ } catch (error) {
96
+ return { errors: [error instanceof Error ? error.message : String(error)], warnings, doc: null };
97
+ }
98
+
99
+ const meta = doc.meta;
100
+ const topic = typeof meta.topic === "string" ? meta.topic : "";
101
+ if (!topic) {
102
+ errors.push("frontmatter: required field 'topic' is missing");
103
+ } else if (!TOPIC_RE.test(topic)) {
104
+ errors.push(`frontmatter: topic '${topic}' must be lowercase kebab-case`);
105
+ } else if (basename(path, ".md") !== topic) {
106
+ errors.push(`frontmatter: topic '${topic}' must match the file name '${basename(path)}'`);
107
+ }
108
+
109
+ if (typeof meta.question !== "string" || !meta.question.trim()) {
110
+ errors.push("frontmatter: required field 'question' is missing or empty");
111
+ }
112
+ for (const field of ["created", "retrieved", "expires"] as const) {
113
+ if (!isValidDate(meta[field])) {
114
+ errors.push(`frontmatter: '${field}' must be a valid date (YYYY-MM-DD)`);
115
+ }
116
+ }
117
+ if (!Array.isArray(meta.stack) || !meta.stack.every((entry) => typeof entry === "string")) {
118
+ errors.push("frontmatter: 'stack' must be a list of version strings");
119
+ }
120
+
121
+ if (isValidDate(meta.created) && isValidDate(meta.retrieved) && meta.created > meta.retrieved) {
122
+ errors.push("frontmatter: 'created' must not be after 'retrieved'");
123
+ }
124
+ if (isValidDate(meta.retrieved) && isValidDate(meta.expires) && meta.expires <= meta.retrieved) {
125
+ errors.push("frontmatter: 'expires' must be after 'retrieved'");
126
+ }
127
+
128
+ if (!/^#\s+Research:\s*\S/m.test(doc.body)) {
129
+ errors.push("body: heading '# Research: <topic>' is missing");
130
+ }
131
+ if (!/^##\s+Question\s*$/m.test(doc.body)) {
132
+ errors.push("body: section '## Question' is missing");
133
+ }
134
+ if (!/^##\s+Findings\s*$/m.test(doc.body)) {
135
+ errors.push("body: section '## Findings' is missing");
136
+ }
137
+ if (!/^##\s+Conflicts, caveats and gaps\s*$/m.test(doc.body)) {
138
+ warnings.push("body: section '## Conflicts, caveats and gaps' is missing – state gaps or say that there are none");
139
+ }
140
+ if (!/^##\s+Open questions for the grilling\s*$/m.test(doc.body)) {
141
+ warnings.push("body: section '## Open questions for the grilling' is missing");
142
+ }
143
+
144
+ const headings = [...doc.body.matchAll(/^###\s+(F\d+)\s*[–-]\s*(.*)$/gm)];
145
+ if (!headings.length) {
146
+ errors.push("body: at least one finding ('### F1 – <short claim>') is required");
147
+ }
148
+ for (const heading of headings) {
149
+ const start = (heading.index ?? 0) + heading[0].length;
150
+ const rest = doc.body.slice(start);
151
+ const next = rest.search(/^###\s+/m);
152
+ const block = next === -1 ? rest : rest.slice(0, next);
153
+ for (const field of FINDING_FIELDS) {
154
+ const value = new RegExp(`^-\\s*\\*\\*${field}:\\*\\*\\s*(.+)$`, "m").exec(block)?.[1].trim();
155
+ if (!value) errors.push(`finding ${heading[1]}: field '${field}' is missing or empty`);
156
+ }
157
+ const tier = /^-\s*\*\*Tier:\*\*\s*(\S+)/m.exec(block)?.[1];
158
+ if (tier && !/^[123]$/.test(tier)) errors.push(`finding ${heading[1]}: tier '${tier}' must be 1, 2 or 3`);
159
+ const confidence = /^-\s*\*\*Confidence:\*\*\s*(\S+)/m.exec(block)?.[1]?.toLowerCase();
160
+ if (confidence && !/^(high|medium|low)$/.test(confidence)) {
161
+ errors.push(`finding ${heading[1]}: confidence '${confidence}' must be high, medium or low`);
162
+ }
163
+ const retrieved = /^-\s*\*\*Retrieved:\*\*\s*(\S+)/m.exec(block)?.[1];
164
+ if (retrieved && !isValidDate(retrieved)) {
165
+ errors.push(`finding ${heading[1]}: retrieved '${retrieved}' must be a valid date (YYYY-MM-DD)`);
166
+ }
167
+ }
168
+
169
+ return { errors, warnings, doc };
170
+ }
171
+
172
+ export interface ResearchState {
173
+ status: ResearchStatus;
174
+ path: string;
175
+ relPath: string;
176
+ topic: string;
177
+ question: string;
178
+ retrieved: string;
179
+ expires: string;
180
+ errors: string[];
181
+ warnings: string[];
182
+ }
183
+
184
+ /** Format and expiry of one research file. Expiry is compared against today, never recomputed from the config. */
185
+ export function researchStatus(path: string, now = today()): ResearchState {
186
+ const base: ResearchState = {
187
+ status: "missing",
188
+ path,
189
+ relPath: relPath(path),
190
+ topic: basename(path, ".md"),
191
+ question: "",
192
+ retrieved: "",
193
+ expires: "",
194
+ errors: [],
195
+ warnings: [],
196
+ };
197
+ if (!existsSync(path)) return base;
198
+
199
+ const validation = validateResearchFile(path);
200
+ const meta = validation.doc?.meta ?? {};
201
+ const state: ResearchState = {
202
+ ...base,
203
+ topic: typeof meta.topic === "string" ? meta.topic : base.topic,
204
+ question: typeof meta.question === "string" ? meta.question : "",
205
+ retrieved: typeof meta.retrieved === "string" ? meta.retrieved : "",
206
+ expires: typeof meta.expires === "string" ? meta.expires : "",
207
+ errors: validation.errors,
208
+ warnings: validation.warnings,
209
+ };
210
+ if (validation.errors.length) return { ...state, status: "invalid" };
211
+ if (isValidDate(state.expires) && state.expires < now) return { ...state, status: "expired" };
212
+ return { ...state, status: "valid" };
213
+ }
214
+
215
+ /** A local research reference: `docs/research/<topic>.md`, optionally with `#anchor`. */
216
+ export function isLocalResearchRef(ref: string): boolean {
217
+ return /^docs\/research\/[A-Za-z0-9._-]+\.md(?:#.*)?$/.test(ref.trim());
218
+ }
219
+
220
+ /** A source is a web URL or a local research file. */
221
+ export function isValidSourceRef(ref: string): boolean {
222
+ return /^https?:\/\/\S+$/i.test(ref.trim()) || isLocalResearchRef(ref);
223
+ }
224
+
225
+ /** Absolute path of a local research reference, without the anchor. */
226
+ export function researchPath(ref: string): string {
227
+ return join(ROOT, ref.trim().split("#", 1)[0]);
228
+ }
229
+
230
+ /** The `sources:` entries of a story, as written. */
231
+ export function sourceRefs(meta: Obj): string[] {
232
+ return Array.isArray(meta.sources)
233
+ ? meta.sources.filter((value): value is string => typeof value === "string" && value.trim() !== "").map((value) => value.trim())
234
+ : [];
235
+ }
236
+
237
+ /**
238
+ * The gate: a story with one of the configured risks or labels needs research evidence before it leaves `review`.
239
+ * Returns the trigger (`risk 'high'`, `label 'security:oauth'`, or both), or null when the story is not gated.
240
+ * A label matches exactly or by prefix: `security` matches `security:oauth`.
241
+ */
242
+ export function researchGateRequired(meta: Obj, cfg: ResearchConfig = loadResearchConfig()): string | null {
243
+ const triggers: string[] = [];
244
+ const risk = typeof meta.risk === "string" ? meta.risk.toLowerCase() : "";
245
+ if (risk && cfg.gate.risk.includes(risk)) triggers.push(`risk '${risk}'`);
246
+
247
+ const labels = Array.isArray(meta.labels)
248
+ ? meta.labels.filter((value): value is string => typeof value === "string").map((value) => value.toLowerCase())
249
+ : [];
250
+ const matched = labels.find((label) =>
251
+ cfg.gate.labels.some((configured) => label === configured || label.startsWith(`${configured}:`)),
252
+ );
253
+ if (matched) triggers.push(`label '${matched}'`);
254
+
255
+ return triggers.length ? triggers.join(" / ") : null;
256
+ }
257
+
258
+ /**
259
+ * Hard-gate errors for the transition to `ready-for-human-review` or `ready`.
260
+ *
261
+ * A source counts when it is `https://…` or a local `docs/research/<topic>.md`; a local file must exist. Expiry is
262
+ * a warning elsewhere, never a gate error: an expired file still records where the evidence came from.
263
+ */
264
+ export function researchGateErrors(meta: Obj, cfg: ResearchConfig = loadResearchConfig()): string[] {
265
+ const trigger = researchGateRequired(meta, cfg);
266
+ if (!trigger) return [];
267
+
268
+ const sources = sourceRefs(meta);
269
+ if (!sources.length) {
270
+ return [`${trigger} requires at least one 'sources:' entry before ready-for-human-review`];
271
+ }
272
+
273
+ const valid = sources.filter(isValidSourceRef);
274
+ if (!valid.length) {
275
+ return [`${trigger} has no resolvable source – use https://… or docs/research/<topic>.md`];
276
+ }
277
+ if (valid.some((ref) => /^https?:\/\//i.test(ref))) return [];
278
+
279
+ const local = valid.filter(isLocalResearchRef);
280
+ const missing = local.filter((ref) => !existsSync(researchPath(ref)));
281
+ if (local.length && missing.length === local.length) {
282
+ return [`${trigger} has no resolvable source – missing: ${missing.join(", ")}`];
283
+ }
284
+ return [];
285
+ }
286
+
287
+ export interface ExpiredResearchRef {
288
+ ref: string;
289
+ path: string;
290
+ relPath: string;
291
+ expires: string;
292
+ }
293
+
294
+ /** Local research references of a story whose expiry has passed. Advisory: verify prints them, the gate does not. */
295
+ export function expiredResearchRefs(meta: Obj, cfg: ResearchConfig = loadResearchConfig(), now = today()): ExpiredResearchRef[] {
296
+ void cfg;
297
+ const out: ExpiredResearchRef[] = [];
298
+ for (const ref of sourceRefs(meta).filter(isLocalResearchRef)) {
299
+ const state = researchStatus(researchPath(ref), now);
300
+ if (state.status === "expired") out.push({ ref, path: state.path, relPath: state.relPath, expires: state.expires });
301
+ }
302
+ return out;
303
+ }