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.
- package/.claude/THIRD_PARTY_NOTICES.md +4 -0
- package/.claude/agents/acceptance-tester.md +7 -2
- package/.claude/agents/code-reviewer.md +7 -2
- package/.claude/agents/domain-architect.md +7 -3
- package/.claude/agents/researcher.md +77 -0
- package/.claude/agents/ux-designer.md +42 -47
- package/.claude/hooks/validate-docs.ts +17 -1
- package/.claude/lib/architecture.ts +3 -33
- package/.claude/lib/backlog.ts +2 -0
- package/.claude/lib/config.ts +49 -0
- package/.claude/lib/discovery.ts +200 -32
- package/.claude/lib/engineering.ts +18 -6
- package/.claude/lib/gaps.ts +179 -0
- package/.claude/lib/glob.ts +39 -0
- package/.claude/lib/repo.ts +143 -19
- package/.claude/lib/research.ts +303 -0
- package/.claude/lib/slices.ts +529 -0
- package/.claude/package.json +1 -0
- package/.claude/settings.json +2 -2
- package/.claude/skills/adopt/SKILL.md +111 -34
- package/.claude/skills/adopt/scripts/gap-register.ts +61 -0
- package/.claude/skills/adopt/scripts/repo-map.ts +34 -2
- package/.claude/skills/adopt/scripts/slice-doc.ts +117 -0
- package/.claude/skills/adopt/scripts/slice.ts +163 -0
- package/.claude/skills/adopt/templates/context-map.as-is.md +22 -0
- package/.claude/skills/architect/SKILL.md +6 -1
- package/.claude/skills/backlog/SKILL.md +2 -2
- package/.claude/skills/bootstrap/SKILL.md +9 -7
- package/.claude/skills/domain-model/CONTEXT-FORMAT.md +1 -1
- package/.claude/skills/domain-model/SKILL.md +24 -4
- package/.claude/skills/event-storming/SKILL.md +3 -2
- package/.claude/skills/event-storming/schema/events.schema.json +2 -0
- package/.claude/skills/event-storming/templates/events.example.yaml +6 -0
- package/.claude/skills/feature/SKILL.md +16 -13
- package/.claude/skills/feature/scripts/intake.ts +1 -1
- package/.claude/skills/grilling/SKILL.md +15 -0
- package/.claude/skills/groom-backlog/SKILL.md +19 -17
- package/.claude/skills/implement/SKILL.md +199 -78
- package/.claude/skills/implement/scripts/characterize.ts +252 -0
- package/.claude/skills/implement/scripts/check-scenarios.ts +1 -1
- package/.claude/skills/implement/scripts/story-context.ts +65 -1
- package/.claude/skills/implement/scripts/verify.ts +4 -1
- package/.claude/skills/improve-codebase-architecture/SKILL.md +4 -1
- package/.claude/skills/prototype/SKILL.md +1 -1
- package/.claude/skills/research/SKILL.md +149 -0
- package/.claude/skills/research/scripts/research-check.ts +105 -0
- package/.claude/skills/research/templates/research.md +29 -0
- package/.claude/skills/review-stories/SKILL.md +49 -19
- package/.claude/skills/shape/SKILL.md +26 -7
- package/.claude/skills/tdd/SKILL.md +10 -0
- package/.claude/skills/user-stories/SKILL.md +128 -51
- package/.claude/skills/user-stories/schema/story.schema.json +10 -3
- package/.claude/skills/user-stories/scripts/lint-story.ts +56 -0
- package/.claude/skills/user-stories/templates/story-template.md +6 -2
- package/.claude/skills/ux-design/SKILL.md +9 -7
- package/.opencode/agents/acceptance-tester.md +8 -3
- package/.opencode/agents/code-reviewer.md +8 -3
- package/.opencode/agents/domain-architect.md +7 -3
- package/.opencode/agents/researcher.md +91 -0
- package/.opencode/agents/ux-designer.md +42 -52
- package/.opencode/commands/adopt.md +1 -1
- package/.opencode/commands/bootstrap.md +1 -1
- package/.opencode/commands/feature.md +1 -1
- package/.opencode/commands/groom-backlog.md +1 -1
- package/.opencode/commands/implement.md +1 -1
- package/.opencode/commands/research.md +7 -0
- package/.opencode/commands/review-stories.md +1 -1
- package/.opencode/commands/shape.md +1 -1
- package/AGENTS.md +30 -0
- package/CHANGELOG.md +73 -0
- package/README.md +108 -24
- package/guide/brownfield.md +41 -12
- package/guide/concepts.md +23 -6
- package/guide/greenfield.md +4 -3
- package/package.json +1 -1
- package/src/cli.mjs +42 -4
- package/src/detect.mjs +9 -0
- package/src/install.mjs +66 -6
- package/templates/docs/domain/context-map.md +5 -1
- package/templates/docs/domain/events.yaml +3 -0
- package/templates/docs/research/README.md +70 -0
- package/templates/docs/stories/README.md +12 -3
- package/templates/docs/workflow.md +76 -19
- package/.claude/agents/lead-dev.md +0 -52
- package/.claude/agents/product-owner.md +0 -39
- package/.claude/agents/requirements-engineer.md +0 -44
- package/.opencode/agents/lead-dev.md +0 -69
- package/.opencode/agents/product-owner.md +0 -59
- package/.opencode/agents/requirements-engineer.md +0 -58
package/.claude/lib/discovery.ts
CHANGED
|
@@ -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
|
|
49
|
-
|
|
50
|
-
export const
|
|
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 (
|
|
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"
|
|
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 (
|
|
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
|
-
|
|
518
|
-
|
|
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
|
|
558
|
-
if (!
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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"
|
|
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
|
|
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
|
|
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;
|
|
70
|
-
* and sends a story back to review when it turns
|
|
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
|
|
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
|
+
}
|