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
@@ -15,7 +15,8 @@
15
15
  import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
16
16
  import { basename, join, relative, resolve, sep } from "node:path";
17
17
  import { parse as parseYaml, YAMLError } from "yaml";
18
- import { isManagedLabel, labelPrefix, loadBacklogConfig } from "./config.ts";
18
+ import { isManagedLabel, labelPrefix, loadBacklogConfig, loadResearchConfig } from "./config.ts";
19
+ import * as research from "./research.ts";
19
20
 
20
21
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
22
  export type Obj = Record<string, any>;
@@ -44,13 +45,49 @@ const KIND_LABEL: Record<string, string> = {
44
45
  * `superseded`: never built as written – its work was done elsewhere (a direct change, another story) or a new planning
45
46
  * replaces it. The file stays as the record other documents link to; it is final and counts toward no progress (user,
46
47
  * 2026-10-25).
48
+ *
49
+ * `ready-for-human-review` is set by the linter gate: a story whose spec passes every mechanical check waits there
50
+ * until the user approves it as `ready`. Engineering only picks up `ready` stories.
51
+ */
52
+ export const STATUSES = ["draft", "review", "ready-for-human-review", "ready", "in-progress", "done", "superseded"] as const;
53
+ /** Statuses that require a finished story: no [OPEN] markers and a consistent spec. */
54
+ export const COMMITTED = new Set(["ready-for-human-review", "ready", "in-progress", "done"]);
55
+ /** Statuses handed to engineering or the tracker: the tracker link is required from `ready` on, not before. */
56
+ const HANDOFF = new Set(["ready", "in-progress", "done"]);
57
+ /**
58
+ * 1 command + 1 aggregate, at most 4 scenarios: one happy path and up to three edge/error cases. Each scenario is a
59
+ * red → green cycle in /implement; more than four means the story merges commands or restates UX guidelines.
60
+ */
61
+ export const MAX_SCENARIOS = 4;
62
+ /**
63
+ * Story types.
64
+ *
65
+ * domain – full DDD/TDD path: exactly one command on one aggregate, Gherkin traceability, reviews
66
+ * chore/task – fast lane: UI tweaks, refactorings, dependencies, setup; no Gherkin matching, no formal reviews
67
+ * tech-task – deliberate technical groundwork with a checklist
68
+ * spike – a timeboxed question
69
+ * story – the legacy name of `domain`; still valid, reported as a warning
47
70
  */
48
- export const STATUSES = ["draft", "review", "ready", "in-progress", "done", "superseded"] as const;
49
- /** Statuses that require a finished story: size set, no [OPEN] markers. */
50
- export const COMMITTED = new Set(["ready", "in-progress", "done"]);
51
- export const MAX_SCENARIOS = 10;
71
+ export const DOMAIN_TYPES = new Set(["domain", "story"]);
72
+ export const FAST_TYPES = new Set(["chore", "task"]);
73
+ export const LEGACY_TYPES: Record<string, string> = { story: "domain" };
52
74
  const PRIORITIES = ["must", "should", "could", "wont"];
53
75
 
76
+ /**
77
+ * Aggregate status.
78
+ *
79
+ * target – the boundary the project wants (the default; a missing field means target)
80
+ * as-is – the factual consistency boundary recovered from code, with evidence
81
+ * unresolved – the boundary is unclear; it needs an open hotspot and grilling, and no story on it can be ready
82
+ */
83
+ export const AGGREGATE_STATUSES = ["target", "as-is", "unresolved"] as const;
84
+ export type AggregateStatus = (typeof AGGREGATE_STATUSES)[number];
85
+
86
+ export function aggregateStatus(aggregate: Obj | undefined): AggregateStatus {
87
+ const value = aggregate?.status;
88
+ return value === "as-is" || value === "unresolved" ? value : "target";
89
+ }
90
+
54
91
  // --------------------------------------------------------------------------
55
92
  // Report
56
93
  // --------------------------------------------------------------------------
@@ -82,6 +119,13 @@ const list = (v: unknown): string[] => (Array.isArray(v) ? v : []);
82
119
  const items = (model: Obj, key: string): Obj[] => list(model[key]) as unknown as Obj[];
83
120
  const prefix = (id: string): string => id.split("-", 1)[0];
84
121
 
122
+ /** Domain story: full DDD/TDD path. `story` is the legacy name of `domain`. */
123
+ export const isDomain = (meta: Obj): boolean => DOMAIN_TYPES.has(String(meta.type));
124
+ /** Fast lane: chore or task – no Gherkin matching, no formal subagent reviews. */
125
+ export const isFastLane = (meta: Obj): boolean => FAST_TYPES.has(String(meta.type));
126
+ /** A story with a frontend part: the explicit `ui: true` flag, or the legacy `ui` label. */
127
+ export const isUi = (meta: Obj): boolean => meta.ui === true || list(meta.labels).includes("ui");
128
+
85
129
  /** Resolves symlinks where possible so paths from hooks and from ROOT compare equal. */
86
130
  export function realPath(p: string): string {
87
131
  try {
@@ -277,6 +321,25 @@ export function validateEvents(report: Report): Obj | null {
277
321
  for (const h of items(m, "hotspots")) ref(h.id, "refs", h.refs, allButFlow);
278
322
  for (const f of items(m, "flows")) ref(f.id, "sequence", f.sequence, allButFlow);
279
323
 
324
+ // Aggregate status: as-is is a factual boundary and needs evidence; unresolved needs an open question.
325
+ const openHotspotRefs = new Set(
326
+ items(m, "hotspots")
327
+ .filter((h) => (h.status ?? "open") === "open")
328
+ .flatMap((h) => list(h.refs)),
329
+ );
330
+ for (const a of items(m, "aggregates")) {
331
+ const status = aggregateStatus(a);
332
+ if (status === "as-is" && !list(a.evidence).length) {
333
+ report.error(
334
+ where,
335
+ `${a.id}: status 'as-is' needs evidence – name the table, service method or path that marks the consistency boundary`,
336
+ );
337
+ }
338
+ if (status === "unresolved" && !openHotspotRefs.has(a.id)) {
339
+ report.warn(where, `${a.id}: status 'unresolved' without an open hotspot – add HS-… with refs: [${a.id}]`);
340
+ }
341
+ }
342
+
280
343
  // Heuristic warnings (non-blocking)
281
344
  const produced = new Set(items(m, "commands").flatMap((c) => list(c.produces)));
282
345
  const policyCmds = new Set(items(m, "policies").flatMap((p) => list(p.then)));
@@ -404,6 +467,17 @@ export function renderEventStorming(model: Obj): string {
404
467
  out.push("");
405
468
  }
406
469
 
470
+ // Aggregate status: target, as-is (factual, with evidence) or unresolved (needs grilling).
471
+ if (it("aggregates").length) {
472
+ out.push("## Aggregates", "", "| Aggregate | Context | Status | Evidence |", "|---|---|---|---|");
473
+ for (const a of it("aggregates")) {
474
+ const context = a.context ? idx.get(a.context)?.name ?? a.context : "–";
475
+ const evidence = list(a.evidence).map((e) => String(e).replaceAll("|", "\\|")).join("<br>") || "–";
476
+ out.push(`| ${a.name} (\`${a.id}\`) | ${context} | ${aggregateStatus(a)} | ${evidence} |`);
477
+ }
478
+ out.push("");
479
+ }
480
+
407
481
  // Hotspots
408
482
  const hs = it("hotspots").filter((h) => (h.status ?? "open") === "open");
409
483
  if (hs.length) {
@@ -489,8 +563,8 @@ export const SCENARIO_RE = /^\s*(?:Scenario Outline|Scenario Template|Scenario|E
489
563
 
490
564
  function checkBody(r: Report, where: string, meta: Obj, body: string): void {
491
565
  const secs = sections(body);
492
- const type = meta.type;
493
- if (type === "story") {
566
+ const type = String(meta.type);
567
+ if (isDomain(meta)) {
494
568
  const s = section(secs, "story");
495
569
  if (s === null) {
496
570
  r.error(where, "section '## Story' is missing");
@@ -499,7 +573,9 @@ function checkBody(r: Report, where: string, meta: Obj, body: string): void {
499
573
  }
500
574
  }
501
575
  if (type === "spike" && section(secs, "question") === null) r.error(where, "spike needs a '## Question' section");
502
- if (type === "tech-task" && section(secs, "task") === null) r.error(where, "tech task needs a '## Task' section");
576
+ if (type === "tech-task" || type === "task" || type === "chore") {
577
+ if (section(secs, "task") === null) r.error(where, `${type} needs a '## Task' section`);
578
+ }
503
579
 
504
580
  const ac = section(secs, "acceptance criteria");
505
581
  if (ac === null) {
@@ -511,12 +587,11 @@ function checkBody(r: Report, where: string, meta: Obj, body: string): void {
511
587
  const titles = scenarios.map((sc) => sc.split("\n")[0].trim().toLowerCase());
512
588
  const dupes = titles.filter((t, n) => t && titles.indexOf(t) !== n);
513
589
  if (dupes.length) r.error(where, `scenario titles must be unique within the story: ${[...new Set(dupes)].join(", ")}`);
514
- if (type === "story") {
590
+ if (isDomain(meta)) {
515
591
  if (scenarios.length < 2) r.error(where, "at least 2 Gherkin scenarios required (happy path + error/edge case)");
516
592
  scenarios.forEach((sc, n) => {
517
- if (!/^\s*When\b/m.test(sc) || !/^\s*Then\b/m.test(sc)) {
518
- r.error(where, `scenario ${n + 1}: 'When' and 'Then' are required`);
519
- }
593
+ const missing = ["Given", "When", "Then"].filter((step) => !new RegExp(`^\\s*${step}\\b`, "m").test(sc));
594
+ if (missing.length) r.error(where, `scenario ${n + 1}: ${missing.join(", ")} required (Gherkin Given/When/Then)`);
520
595
  });
521
596
  } else if (!scenarios.length && !/^\s*[-*]\s*\[[ xX]\]/m.test(ac)) {
522
597
  r.error(where, "give acceptance criteria as scenarios or a checklist ('- [ ] …')");
@@ -554,8 +629,8 @@ export function scenarioTitlesIn(body: string): string[] {
554
629
  * and stays the authority.
555
630
  */
556
631
  export function completenessWarnings(story: Story): string[] {
557
- if (story.meta.type !== "story") return []; // spikes and tech tasks carry checklists, not screens
558
- if (!list(story.meta.labels).includes("ui")) return [];
632
+ if (!isDomain(story.meta)) return []; // fast lane, spikes and tech tasks carry checklists, not screens
633
+ if (!isUi(story.meta)) return [];
559
634
  const ac = section(sections(story.body), "acceptance criteria") ?? "";
560
635
  if (!ac.trim()) return [];
561
636
  const titles = scenarioTitlesIn(story.body);
@@ -609,6 +684,7 @@ export function validateStories(report: Report, only: string | null = null, leni
609
684
  const stories = new Map<string, Story>();
610
685
  for (const s of parsed) if (storyId(s)) stories.set(storyId(s), s);
611
686
  const backlog = loadBacklogConfig();
687
+ const researchCfg = loadResearchConfig();
612
688
 
613
689
  for (const s of parsed) {
614
690
  const r = isMine(s.path) ? report : new Report();
@@ -638,24 +714,113 @@ export function validateStories(report: Report, only: string | null = null, leni
638
714
  else r.error(where, msg);
639
715
  }
640
716
  }
641
- const status = m.status;
642
- if (COMMITTED.has(status) && !m.size) r.error(where, `size is missing – lead-dev must estimate before '${status}'`);
643
- if (m.size === "XL") {
644
- const msg = "size XL – split the story";
645
- if (COMMITTED.has(status)) r.error(where, msg);
646
- else r.warn(where, msg);
717
+ const status = String(m.status);
718
+ const type = String(m.type);
719
+ // The strict 1-command rules apply to the new `domain` type. `story` is the legacy name and stays valid
720
+ // (warnings only), so an upgrade does not turn an existing backlog red.
721
+ const strict = type === "domain";
722
+ if (LEGACY_TYPES[type]) {
723
+ r.warn(where, `type '${type}' is the legacy name of '${LEGACY_TYPES[type]}' – use '${LEGACY_TYPES[type]}'`);
724
+ }
725
+ if (!isDomain(m) && !isFastLane(m) && type !== "spike" && type !== "tech-task") {
726
+ r.error(where, `unknown type '${type}' (domain, chore, task, tech-task, spike)`);
727
+ }
728
+ if (strict) {
729
+ if (!m.context) r.error(where, "domain story needs a bounded context ('context: BC-…')");
730
+ if (!m.aggregate) r.error(where, "domain story needs exactly one aggregate ('aggregate: AGG-…')");
731
+ if (!m.command) r.error(where, "domain story needs exactly one command ('command: CMD-…')");
732
+ }
733
+ if (isDomain(m) && !strict) {
734
+ if (!m.aggregate || !m.command) {
735
+ r.warn(where, "legacy story without aggregate/command – the 1-command rule cannot be checked; migrate it to type: domain");
736
+ }
737
+ }
738
+ if (idx !== null) {
739
+ const ctx = m.context;
740
+ if (typeof ctx === "string" && ctx && (!idx.has(ctx) || prefix(ctx) !== "BC")) {
741
+ r.error(where, `context '${ctx}' does not exist in events.yaml`);
742
+ }
743
+ for (const ev of list(m.events)) {
744
+ if (!idx.has(ev) || prefix(ev) !== "EVT") r.error(where, `event '${ev}' does not exist in events.yaml`);
745
+ }
746
+ if (m.aggregate !== undefined) {
747
+ if (typeof m.aggregate !== "string" || !idx.has(m.aggregate) || prefix(m.aggregate) !== "AGG") {
748
+ r.error(where, `aggregate '${fmt(m.aggregate)}' does not exist in events.yaml`);
749
+ }
750
+ }
751
+ if (m.command !== undefined) {
752
+ if (typeof m.command !== "string" || !idx.has(m.command) || prefix(m.command) !== "CMD") {
753
+ r.error(where, `command '${fmt(m.command)}' does not exist in events.yaml`);
754
+ }
755
+ }
756
+ // The 1-command rule: one story = one command + its events on one aggregate.
757
+ const cmd = typeof m.command === "string" ? idx.get(m.command) : undefined;
758
+ if (cmd && typeof m.aggregate === "string" && cmd.aggregate && cmd.aggregate !== m.aggregate) {
759
+ r.error(where, `command '${m.command}' belongs to aggregate '${cmd.aggregate}', not '${m.aggregate}'`);
760
+ }
761
+ if (strict && cmd) {
762
+ const produced = list(cmd.produces);
763
+ const linked = list(m.events);
764
+ if (!produced.length) {
765
+ r.error(where, `command '${m.command}' produces no event in events.yaml`);
766
+ } else {
767
+ const missing = produced.filter((ev) => !linked.includes(ev));
768
+ const extra = linked.filter((ev) => !produced.includes(ev));
769
+ if (missing.length || extra.length) {
770
+ r.error(
771
+ where,
772
+ `events must be exactly what command '${m.command}' produces (${produced.join(", ")})` +
773
+ (missing.length ? ` – missing: ${missing.join(", ")}` : "") +
774
+ (extra.length ? ` – not produced: ${extra.join(", ")}` : ""),
775
+ );
776
+ }
777
+ }
778
+ }
779
+ const aggregate = typeof m.aggregate === "string" ? idx.get(m.aggregate) : undefined;
780
+ if (aggregate && aggregateStatus(aggregate) === "unresolved") {
781
+ const msg =
782
+ `aggregate '${m.aggregate}' is unresolved – the consistency boundary is not settled; ` +
783
+ "resolve its hotspot in grilling before the story can be ready";
784
+ if (COMMITTED.has(status)) r.error(where, msg);
785
+ else r.warn(where, msg);
786
+ }
787
+ } else if (isDomain(m)) {
788
+ // Without a readable model the required fields are checked above; the cross-check cannot run.
789
+ r.warn(where, "events.yaml is missing or invalid – the aggregate/command cross-check was skipped");
647
790
  }
648
- // Every scenario is a red → green cycle in /implement; past ten the story is too big or restates UX
649
- // guideline patterns (user, 2026-10-26). `ready` ones were approved under the older rules.
791
+ for (const dep of list(m.depends_on)) {
792
+ if (dep === id) {
793
+ r.error(where, "depends on itself");
794
+ } else if (!stories.has(dep)) {
795
+ const msg = `depends_on '${dep}' does not exist`;
796
+ if (lenientDeps) r.warn(where, msg);
797
+ else r.error(where, msg);
798
+ }
799
+ }
800
+ // Research is evidence, not a specification. A gated story must cite at least one source before the linter
801
+ // gate; the hard check runs on the status transition (engineering.ts). Expiry belongs to verify.ts.
802
+ const sourceRefs = research.sourceRefs(m);
803
+ for (const ref of sourceRefs) {
804
+ if (!research.isValidSourceRef(ref)) {
805
+ r.warn(where, `source '${ref}' is neither an http(s) URL nor a docs/research/<topic>.md reference`);
806
+ } else if (research.isLocalResearchRef(ref) && !existsSync(research.researchPath(ref))) {
807
+ r.warn(where, `research source '${ref}' does not exist – collect it with /research or remove the reference`);
808
+ }
809
+ }
810
+ if ((status === "draft" || status === "review") && sourceRefs.length === 0) {
811
+ const trigger = research.researchGateRequired(m, researchCfg);
812
+ if (trigger) r.warn(where, `${trigger} requires at least one 'sources:' entry before ready-for-human-review`);
813
+ }
814
+
815
+ if (m.size === "XL") r.warn(where, "size XL – split the story");
816
+ // Every scenario is a red → green cycle in /implement; past four the story merges commands or restates UX
817
+ // guideline patterns. Domain stories must obey it; legacy `story` files get a warning until migrated.
650
818
  const scenarioTotal = scenarioTitlesIn(s.body).length;
651
- if (m.type === "story" && scenarioTotal > MAX_SCENARIOS && (status === "draft" || status === "review")) {
652
- const msg = `${scenarioTotal} scenarios, at most ${MAX_SCENARIOS} – split the story, or reference the UX guideline (G-number) instead of restating its pattern as scenarios`;
653
- if (status === "review") r.error(where, msg);
819
+ if (isDomain(m) && scenarioTotal > MAX_SCENARIOS && (status === "draft" || status === "review" || status === "ready-for-human-review")) {
820
+ const msg = `${scenarioTotal} scenarios, at most ${MAX_SCENARIOS} – split the story (one command, one aggregate), or reference the UX guideline (G-number) instead of restating its pattern as scenarios`;
821
+ if (strict && status !== "draft") r.error(where, msg);
654
822
  else r.warn(where, msg);
655
823
  }
656
- if (m.type === "story" && COMMITTED.has(status) && !list(m.events).length) {
657
- r.warn(where, "no domain events linked – traceability is missing");
658
- }
659
824
 
660
825
  // In tracker mode the Markdown file is a mirror. A story that is handed to engineering must carry its
661
826
  // external key, otherwise the next pull cannot match it and the tracker is not the source of truth any more.
@@ -668,7 +833,7 @@ export function validateStories(report: Report, only: string | null = null, leni
668
833
  r.error(where, "external.key is missing");
669
834
  }
670
835
  }
671
- if (COMMITTED.has(status) && !(ext && typeof ext === "object" && typeof ext.key === "string" && ext.key)) {
836
+ if (HANDOFF.has(status) && !(ext && typeof ext === "object" && typeof ext.key === "string" && ext.key)) {
672
837
  r.error(where, `not linked to the ${backlog.provider} backlog – run /backlog-sync push before '${status}'`);
673
838
  }
674
839
  const prefix = labelPrefix(backlog);
@@ -680,7 +845,9 @@ export function validateStories(report: Report, only: string | null = null, leni
680
845
  checkBody(r, where, m, s.body);
681
846
  // Completeness is checked while a story is being written; `ready` ones were approved under the older rules
682
847
  // and are challenged in `/groom-backlog` instead (user, 2026-10-01).
683
- if (status === "draft" || status === "review") for (const w of completenessWarnings(s)) r.warn(where, w);
848
+ if (status === "draft" || status === "review" || status === "ready-for-human-review") {
849
+ for (const w of completenessWarnings(s)) r.warn(where, w);
850
+ }
684
851
  }
685
852
 
686
853
  // Cycles in depends_on
@@ -710,6 +877,7 @@ export function validateStories(report: Report, only: string | null = null, leni
710
877
  const STATUS_TITLES: [string, string][] = [
711
878
  ["in-progress", "In Progress"],
712
879
  ["ready", "Ready"],
880
+ ["ready-for-human-review", "Ready for Human Review"],
713
881
  ["review", "In Review"],
714
882
  ["draft", "Draft"],
715
883
  ["done", "Done"],
@@ -763,14 +931,14 @@ export function renderBacklog(stories: Story[], model: Obj | null): string {
763
931
  );
764
932
  }
765
933
  if (!stories.length) {
766
- out.push("_No stories yet. Derive them from the event storming with `@requirements-engineer`._", "");
934
+ out.push("_No stories yet. Derive them from the event storming as a spec engineer (main session)._", "");
767
935
  return out.join("\n");
768
936
  }
769
937
 
770
938
  const counts = STATUS_TITLES.map(([st, title]) => [title, stories.filter((s) => s.meta.status === st).length] as const);
771
939
  // Progress is measured in domain stories and their scenarios, not in the total number of stories (CLAUDE.md).
772
940
  // A superseded story was never built as one – its work counts where it was done.
773
- const domain = stories.filter((s) => s.meta.type === "story" && s.meta.status !== "superseded");
941
+ const domain = stories.filter((s) => isDomain(s.meta) && s.meta.status !== "superseded");
774
942
  const scenarioCount = (s: Story) =>
775
943
  (section(sections(s.body), "acceptance criteria") ?? "")
776
944
  .split("\n")
@@ -13,6 +13,7 @@ import { execFileSync } from "node:child_process";
13
13
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { basename, dirname, extname, join } from "node:path";
15
15
  import * as d from "./discovery.ts";
16
+ import * as research from "./research.ts";
16
17
 
17
18
  const list = (v: unknown): string[] => (Array.isArray(v) ? v : []);
18
19
 
@@ -66,13 +67,15 @@ export const testTitle = (id: string, scenario: string): string => `${id}: ${sce
66
67
  // Status workflow
67
68
  // --------------------------------------------------------------------------
68
69
  /**
69
- * Allowed single steps. Discovery owns draft/review/ready; engineering moves ready → in-progress → done
70
- * and sends a story back to review when it turns out to be wrong. `done` is final: changes to finished
71
- * behaviour become a new story.
70
+ * Allowed single steps. Discovery owns draft/review/ready-for-human-review; the linter sets the review state, the
71
+ * user approves `ready`. Engineering moves ready → in-progress → done and sends a story back to review when it turns
72
+ * out to be wrong. `done` is final: changes to finished behaviour become a new story.
72
73
  */
73
74
  const TRANSITIONS: Record<string, string[]> = {
74
75
  draft: ["review", "superseded"],
75
- review: ["draft", "ready", "superseded"],
76
+ review: ["draft", "ready-for-human-review", "superseded"],
77
+ // The linter gate passed; only the user turns this into `ready`.
78
+ "ready-for-human-review": ["draft", "review", "ready", "superseded"],
76
79
  ready: ["review", "in-progress", "superseded"],
77
80
  "in-progress": ["review", "done"],
78
81
  done: [],
@@ -106,7 +109,7 @@ export function transitionErrors(story: d.Story, from: string, to: string, stori
106
109
  errs.push(
107
110
  from === "done" || from === "superseded"
108
111
  ? `a ${from} story is never reopened – capture the change as a new story`
109
- : `status '${from}' cannot become '${to}' (workflow: draft → review → ready → in-progress → done, back to review when a story is wrong; superseded from any status before in-progress)`,
112
+ : `status '${from}' cannot become '${to}' (workflow: draft → review → ready-for-human-review → ready → in-progress → done, back to review when a story is wrong; superseded from any status before in-progress)`,
110
113
  );
111
114
  return errs;
112
115
  }
@@ -114,8 +117,17 @@ export function transitionErrors(story: d.Story, from: string, to: string, stori
114
117
  const open = list(story.meta.depends_on).filter((dep) => stories.get(dep)?.meta.status !== "done");
115
118
  if (open.length) errs.push(`depends on stories that are not done yet: ${open.join(", ")}`);
116
119
  }
120
+ // The research gate is deterministic and applies at the status change, never retroactively: an existing `ready`
121
+ // or `done` story is not re-opened because the gate learned about its risk after approval.
122
+ if (to === "ready-for-human-review" || to === "ready") {
123
+ errs.push(...research.researchGateErrors(story.meta));
124
+ }
125
+ // The linter gate and the user's approval are two separate steps; skipping one defeats the workflow.
126
+ if (to === "ready" && from !== "ready-for-human-review") {
127
+ errs.push("'ready' needs 'ready-for-human-review' first – run the spec linter and let the user approve");
128
+ }
117
129
  if (to === "done") {
118
- if (story.meta.type === "story") {
130
+ if (d.isDomain(story.meta)) {
119
131
  const cov = scenarioCoverage(story);
120
132
  if (cov.missing.length) {
121
133
  errs.push(`scenarios without a test titled '${d.storyId(story)}: <scenario title>': ${cov.missing.map((m) => `"${m}"`).join(", ")}`);
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The gap register: the bridge between the as-is domain recovered from code and the target model.
3
+ *
4
+ * Every aggregate with `status: as-is` or `status: unresolved` gets one generated row. The row links the
5
+ * aggregate in `events.yaml`, the as-is context map and the target context map, so a reader sees both sides of
6
+ * the gap in one place. Decisions and manual gaps stay outside the managed block and survive a refresh.
7
+ *
8
+ * `docs/domain/context-map.md` is the target map; `docs/domain/context-map.as-is.md` is written by `/adopt`.
9
+ * Missing files are not an error: a project that has not adopted anything yet has no gap register either.
10
+ *
11
+ * Node >= 22.18, erasable TypeScript only. No runtime dependency.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { basename, dirname, join } from "node:path";
15
+ import * as d from "./discovery.ts";
16
+
17
+ export const GAP_REGISTER_FILE = "docs/domain/gap-register.md";
18
+ export const GAP_BEGIN = "<!-- kataster:gap-register:begin -->";
19
+ export const GAP_END = "<!-- kataster:gap-register:end -->";
20
+ export const AS_IS_CONTEXT_MAP = "docs/domain/context-map.as-is.md";
21
+ export const TARGET_CONTEXT_MAP = "docs/domain/context-map.md";
22
+
23
+ const list = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
24
+
25
+ export interface ContextEntry {
26
+ id: string;
27
+ heading: string;
28
+ anchor: string;
29
+ }
30
+
31
+ export interface GapRegisterResult {
32
+ path: string;
33
+ status: "created" | "updated" | "unchanged" | "kept";
34
+ }
35
+
36
+ const cell = (value: unknown): string =>
37
+ String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", "<br>") || "–";
38
+
39
+ /** GitHub-style heading anchor, close enough for the context maps the skills write. */
40
+ export function headingAnchor(heading: string): string {
41
+ return heading
42
+ .toLowerCase()
43
+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
44
+ .trim()
45
+ .replace(/\s/g, "-");
46
+ }
47
+
48
+ /**
49
+ * Reads `BC-…` headings from a context map. The heading may carry the display name after a dash:
50
+ * `### BC-Order – Order` and `## BC-Order` both work.
51
+ */
52
+ export function contextEntries(path: string): Map<string, ContextEntry> {
53
+ const out = new Map<string, ContextEntry>();
54
+ if (!existsSync(path)) return out;
55
+ const text = readFileSync(path, "utf8");
56
+ for (const line of text.split("\n")) {
57
+ const match = /^(#{2,4})\s+(BC-[A-Za-z0-9]+)(?:\s+[–-]\s+(.+?))?\s*$/.exec(line);
58
+ if (!match) continue;
59
+ const heading = line.replace(/^#{2,4}\s+/, "").trim();
60
+ out.set(match[2], { id: match[2], heading, anchor: headingAnchor(heading) });
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function contextLink(file: string, entries: Map<string, ContextEntry>, id: string | undefined): string {
66
+ if (!id) return "–";
67
+ const entry = entries.get(id);
68
+ // No heading means the context is not documented on this side yet – that is the gap, not a broken link.
69
+ return entry ? `[${entry.id}](${basename(file)}#${entry.anchor})` : "–";
70
+ }
71
+
72
+ /** The generated rows. Kept separate from the rest of the file so refresh and --check share one renderer. */
73
+ export function renderGapBlock(model: d.Obj | null): string {
74
+ const aggregates = model ? ((model.aggregates ?? []) as d.Obj[]) : [];
75
+ const asIs = contextEntries(join(d.ROOT, AS_IS_CONTEXT_MAP));
76
+ const target = contextEntries(join(d.ROOT, TARGET_CONTEXT_MAP));
77
+ const openHotspots = model
78
+ ? ((model.hotspots ?? []) as d.Obj[]).filter((h) => (h.status ?? "open") === "open")
79
+ : [];
80
+ const hotspotFor = (id: string): string | undefined =>
81
+ openHotspots.find((h) => list(h.refs).includes(id))?.id;
82
+
83
+ const rows: string[] = [
84
+ GAP_BEGIN,
85
+ "<!-- Generated from docs/domain/events.yaml by gap-register.ts. Do not edit inside this block. -->",
86
+ "| Gap | Kind | Aggregate | As-is | Target | Evidence | Next step |",
87
+ "|---|---|---|---|---|---|---|",
88
+ ];
89
+
90
+ for (const aggregate of aggregates) {
91
+ const id = String(aggregate.id ?? "");
92
+ const status = d.aggregateStatus(aggregate);
93
+ if (status !== "as-is" && status !== "unresolved") continue;
94
+ const slug = id.replace(/^AGG-/, "");
95
+ const context = typeof aggregate.context === "string" ? aggregate.context : undefined;
96
+ const hotspot = status === "unresolved" ? hotspotFor(id) : undefined;
97
+ const targetLink = contextLink(TARGET_CONTEXT_MAP, target, context);
98
+ const asIsLink = contextLink(AS_IS_CONTEXT_MAP, asIs, context);
99
+ const evidence = list(aggregate.evidence).map(cell).join("<br>") || "–";
100
+ const next =
101
+ status === "unresolved"
102
+ ? hotspot
103
+ ? `Grill the boundary (${hotspot}); no story on it can be ready`
104
+ : "Grill the boundary; add an open hotspot first"
105
+ : targetLink === "–"
106
+ ? "Decide the target boundary"
107
+ : "Confirm the target boundary or keep it as-is";
108
+ rows.push(
109
+ `| GAP-AGG-${cell(slug)} | ${status === "as-is" ? "as-is boundary" : "unresolved boundary"} | ` +
110
+ `[${id}](events.yaml) | ${asIsLink} | ${targetLink} | ${evidence} | ${next} |`,
111
+ );
112
+ }
113
+
114
+ if (rows.length === 4) rows.push("| – | – | – | – | – | – | no as-is or unresolved aggregate yet |");
115
+ rows.push(GAP_END);
116
+ return rows.join("\n");
117
+ }
118
+
119
+ function emptyRegister(block: string): string {
120
+ return [
121
+ "# Gap register",
122
+ "",
123
+ "> Generated rows come from `docs/domain/events.yaml`; decisions and manual gaps live outside the managed block.",
124
+ `> As-is map: [${basename(AS_IS_CONTEXT_MAP)}](${basename(AS_IS_CONTEXT_MAP)}) · Target map: [${basename(TARGET_CONTEXT_MAP)}](${basename(TARGET_CONTEXT_MAP)}).`,
125
+ "",
126
+ block,
127
+ "",
128
+ "## Decisions",
129
+ "",
130
+ "- none yet",
131
+ "",
132
+ ].join("\n");
133
+ }
134
+
135
+ function replaceBlock(existing: string, block: string): string | null {
136
+ const start = existing.indexOf(GAP_BEGIN);
137
+ const stop = existing.indexOf(GAP_END);
138
+ if (start === -1 || stop === -1 || stop <= start) return null;
139
+ return `${existing.slice(0, start)}${block}${existing.slice(stop + GAP_END.length)}`.replace(/\n{3,}/g, "\n\n");
140
+ }
141
+
142
+ /**
143
+ * Writes or refreshes the managed block. With `onlyIfExists`, a missing register stays missing (used by the
144
+ * PostToolUse hook, so a greenfield project is not given an empty artifact).
145
+ */
146
+ export function writeGapRegister(options: { onlyIfExists?: boolean } = {}): GapRegisterResult | null {
147
+ const path = join(d.ROOT, GAP_REGISTER_FILE);
148
+ const model = d.loadEventsModel();
149
+ const block = renderGapBlock(model);
150
+
151
+ if (!existsSync(path)) {
152
+ if (options.onlyIfExists) return null;
153
+ mkdirSync(dirname(path), { recursive: true });
154
+ writeFileSync(path, emptyRegister(block), "utf8");
155
+ return { path, status: "created" };
156
+ }
157
+
158
+ const existing = readFileSync(path, "utf8");
159
+ const next = replaceBlock(existing, block);
160
+ if (next === null) return { path, status: "kept" };
161
+ if (next === existing) return { path, status: "unchanged" };
162
+ writeFileSync(path, next, "utf8");
163
+ return { path, status: "updated" };
164
+ }
165
+
166
+ export function checkGapRegister(): { ok: boolean; reason: string } {
167
+ const path = join(d.ROOT, GAP_REGISTER_FILE);
168
+ if (!existsSync(path)) return { ok: true, reason: "no gap register yet – /adopt creates it" };
169
+ const existing = readFileSync(path, "utf8");
170
+ const start = existing.indexOf(GAP_BEGIN);
171
+ const stop = existing.indexOf(GAP_END);
172
+ if (start === -1 || stop === -1 || stop <= start) {
173
+ return { ok: true, reason: "the gap register has no managed block – treated as hand-written" };
174
+ }
175
+ const block = renderGapBlock(d.loadEventsModel());
176
+ return replaceBlock(existing, block) === existing
177
+ ? { ok: true, reason: "up to date" }
178
+ : { ok: false, reason: "stale – run node .claude/skills/adopt/scripts/gap-register.ts" };
179
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Repository-relative glob matching shared by the architecture rules and the slice resolver.
3
+ * No dependencies, no project knowledge.
4
+ */
5
+ import { sep } from "node:path";
6
+
7
+ export function toPosixPath(path: string): string {
8
+ return path.split(sep).join("/");
9
+ }
10
+
11
+ export function globToRegExp(glob: string): RegExp {
12
+ const pattern = toPosixPath(glob);
13
+ let out = "^";
14
+ for (let i = 0; i < pattern.length; i += 1) {
15
+ const c = pattern[i];
16
+ if (c === "*") {
17
+ if (pattern[i + 1] === "*") {
18
+ i += 1;
19
+ if (pattern[i + 1] === "/") {
20
+ i += 1;
21
+ out += "(?:.*/)?";
22
+ } else {
23
+ out += ".*";
24
+ }
25
+ } else {
26
+ out += "[^/]*";
27
+ }
28
+ } else if (c === "?") {
29
+ out += "[^/]";
30
+ } else {
31
+ out += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32
+ }
33
+ }
34
+ return new RegExp(`${out}$`);
35
+ }
36
+
37
+ export function matchesAny(path: string, patterns: string[]): boolean {
38
+ return patterns.some((pattern) => globToRegExp(pattern).test(path));
39
+ }