@printpractical/cruze 0.0.1

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 (248) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +167 -0
  3. package/bin/cruze.mjs +17 -0
  4. package/dist/adapters/inbound/cli/cli_context.js +75 -0
  5. package/dist/adapters/inbound/cli/commands/code.js +30 -0
  6. package/dist/adapters/inbound/cli/commands/documents.js +52 -0
  7. package/dist/adapters/inbound/cli/commands/init.js +23 -0
  8. package/dist/adapters/inbound/cli/commands/install.js +15 -0
  9. package/dist/adapters/inbound/cli/commands/journal.js +31 -0
  10. package/dist/adapters/inbound/cli/commands/progress.js +51 -0
  11. package/dist/adapters/inbound/cli/commands/review.js +18 -0
  12. package/dist/adapters/inbound/cli/commands/status.js +45 -0
  13. package/dist/adapters/inbound/cli/commands.js +36 -0
  14. package/dist/adapters/inbound/cli/parse_command.js +90 -0
  15. package/dist/adapters/inbound/cli/run_cli.js +50 -0
  16. package/dist/adapters/outbound/git_repository.js +57 -0
  17. package/dist/adapters/outbound/node_project_files.js +101 -0
  18. package/dist/adapters/outbound/package_bundle.js +56 -0
  19. package/dist/adapters/outbound/process_agent_runner.js +26 -0
  20. package/dist/adapters/outbound/system_clock.js +14 -0
  21. package/dist/adapters/outbound/terminal_prompter.js +22 -0
  22. package/dist/app/ports/agent_runner.js +11 -0
  23. package/dist/app/ports/bundle.js +22 -0
  24. package/dist/app/ports/clock.js +6 -0
  25. package/dist/app/ports/project_files.js +20 -0
  26. package/dist/app/ports/prompter.js +5 -0
  27. package/dist/app/ports/repository.js +13 -0
  28. package/dist/app/project_context.js +45 -0
  29. package/dist/app/use_cases/abandon_work.js +42 -0
  30. package/dist/app/use_cases/approve_artifact.js +146 -0
  31. package/dist/app/use_cases/check_code.js +90 -0
  32. package/dist/app/use_cases/init_project.js +59 -0
  33. package/dist/app/use_cases/install_skills.js +93 -0
  34. package/dist/app/use_cases/journal_events.js +48 -0
  35. package/dist/app/use_cases/land_change.js +113 -0
  36. package/dist/app/use_cases/new_project_doc.js +40 -0
  37. package/dist/app/use_cases/new_work_item.js +75 -0
  38. package/dist/app/use_cases/project_status.js +73 -0
  39. package/dist/app/use_cases/record_progress.js +101 -0
  40. package/dist/app/use_cases/run_review.js +71 -0
  41. package/dist/app/use_cases/validate_project.js +22 -0
  42. package/dist/domain/agent_skill_homes.js +25 -0
  43. package/dist/domain/approvals/evaluate.js +36 -0
  44. package/dist/domain/approvals/fingerprint.js +52 -0
  45. package/dist/domain/approvals/records.js +50 -0
  46. package/dist/domain/architecture_sections.js +23 -0
  47. package/dist/domain/check/check_source.js +64 -0
  48. package/dist/domain/check/imports.js +81 -0
  49. package/dist/domain/check/rust_imports.js +84 -0
  50. package/dist/domain/check/type_count.js +17 -0
  51. package/dist/domain/config.js +128 -0
  52. package/dist/domain/cruze_error.js +10 -0
  53. package/dist/domain/edits/agents_file.js +44 -0
  54. package/dist/domain/edits/managed_tables.js +60 -0
  55. package/dist/domain/edits/progress_block.js +33 -0
  56. package/dist/domain/edits/status_lines.js +52 -0
  57. package/dist/domain/elements.js +126 -0
  58. package/dist/domain/glob.js +46 -0
  59. package/dist/domain/hashing.js +52 -0
  60. package/dist/domain/ids.js +37 -0
  61. package/dist/domain/journal.js +62 -0
  62. package/dist/domain/land/bookkeeping.js +54 -0
  63. package/dist/domain/land/merge_plan.js +132 -0
  64. package/dist/domain/land/text_ops.js +66 -0
  65. package/dist/domain/markdown.js +79 -0
  66. package/dist/domain/markdown_tables.js +34 -0
  67. package/dist/domain/project/artifact_ref.js +42 -0
  68. package/dist/domain/project/deltas.js +49 -0
  69. package/dist/domain/project/layout.js +40 -0
  70. package/dist/domain/project/living_model.js +54 -0
  71. package/dist/domain/project/new_items.js +48 -0
  72. package/dist/domain/project/plan_parts.js +111 -0
  73. package/dist/domain/project/progress.js +43 -0
  74. package/dist/domain/project/project_view.js +72 -0
  75. package/dist/domain/project/roadmap.js +43 -0
  76. package/dist/domain/project/work_items.js +68 -0
  77. package/dist/domain/project_name.js +20 -0
  78. package/dist/domain/scaffold.js +29 -0
  79. package/dist/domain/skill.js +106 -0
  80. package/dist/domain/skill_links.js +103 -0
  81. package/dist/domain/status/next_step.js +119 -0
  82. package/dist/domain/status/verification.js +16 -0
  83. package/dist/domain/status/work_status.js +101 -0
  84. package/dist/domain/trace.js +44 -0
  85. package/dist/domain/validation/delta_rules.js +79 -0
  86. package/dist/domain/validation/element_rules.js +99 -0
  87. package/dist/domain/validation/living_rules.js +97 -0
  88. package/dist/domain/validation/plan_rules.js +105 -0
  89. package/dist/domain/validation/problem.js +21 -0
  90. package/dist/domain/validation/project_rules.js +99 -0
  91. package/dist/domain/validation/validate_project.js +31 -0
  92. package/dist/domain/validation/work_item_rules.js +144 -0
  93. package/dist/main.js +32 -0
  94. package/package.json +40 -0
  95. package/scripts/build.mjs +37 -0
  96. package/skills/about/SKILL.md +21 -0
  97. package/skills/architect/SKILL.md +43 -0
  98. package/skills/architect/feature.md +29 -0
  99. package/skills/architect/project.md +58 -0
  100. package/skills/architect/tweak.md +22 -0
  101. package/skills/architect/walkthrough.md +29 -0
  102. package/skills/behavioural-testing/SKILL.md +63 -0
  103. package/skills/behavioural-testing/examples.md +102 -0
  104. package/skills/behavioural-testing/fakes.md +30 -0
  105. package/skills/build/SKILL.md +62 -0
  106. package/skills/dependency-approval/SKILL.md +48 -0
  107. package/skills/domain-language/SKILL.md +37 -0
  108. package/skills/envision/SKILL.md +29 -0
  109. package/skills/envision/feature.md +22 -0
  110. package/skills/envision/project.md +20 -0
  111. package/skills/explore/SKILL.md +39 -0
  112. package/skills/formats/SKILL.md +84 -0
  113. package/skills/formats/reference/architecture.md +80 -0
  114. package/skills/formats/reference/config.md +57 -0
  115. package/skills/formats/reference/durable-docs.md +71 -0
  116. package/skills/formats/reference/specs.md +65 -0
  117. package/skills/formats/reference/work-items.md +100 -0
  118. package/skills/formats/templates/adr.md +7 -0
  119. package/skills/formats/templates/architecture.md +100 -0
  120. package/skills/formats/templates/change.md +29 -0
  121. package/skills/formats/templates/feature.md +49 -0
  122. package/skills/formats/templates/glossary.md +8 -0
  123. package/skills/formats/templates/roadmap.md +32 -0
  124. package/skills/formats/templates/spec.md +11 -0
  125. package/skills/formats/templates/standalone-change.md +46 -0
  126. package/skills/formats/templates/vision.md +37 -0
  127. package/skills/grilling/SKILL.md +66 -0
  128. package/skills/hexagonal-design/SKILL.md +119 -0
  129. package/skills/hexagonal-design/contracts.md +68 -0
  130. package/skills/hexagonal-design/fundamentals.md +50 -0
  131. package/skills/hexagonal-design/languages/cpp.md +37 -0
  132. package/skills/hexagonal-design/languages/rust.md +40 -0
  133. package/skills/hexagonal-design/runtime.md +48 -0
  134. package/skills/land/SKILL.md +43 -0
  135. package/skills/next/SKILL.md +30 -0
  136. package/skills/plan/SKILL.md +52 -0
  137. package/skills/plan/tasks.md +26 -0
  138. package/skills/plan/test-plan.md +14 -0
  139. package/skills/research/SKILL.md +41 -0
  140. package/skills/research/evaluation.md +27 -0
  141. package/skills/rethink/SKILL.md +53 -0
  142. package/skills/rethink/levels.md +45 -0
  143. package/skills/roadmap/SKILL.md +51 -0
  144. package/skills/roles/SKILL.md +57 -0
  145. package/skills/roles/code-reviewer.md +55 -0
  146. package/skills/roles/design-reviewer.md +63 -0
  147. package/skills/roles/researcher.md +33 -0
  148. package/skills/roles/verifier.md +38 -0
  149. package/skills/triage/SKILL.md +35 -0
  150. package/skills/verify/SKILL.md +39 -0
  151. package/skills/verify/manual-test.md +23 -0
  152. package/src/adapters/inbound/cli/cli_context.ts +75 -0
  153. package/src/adapters/inbound/cli/commands/code.ts +30 -0
  154. package/src/adapters/inbound/cli/commands/documents.ts +52 -0
  155. package/src/adapters/inbound/cli/commands/init.ts +23 -0
  156. package/src/adapters/inbound/cli/commands/install.ts +15 -0
  157. package/src/adapters/inbound/cli/commands/journal.ts +31 -0
  158. package/src/adapters/inbound/cli/commands/progress.ts +51 -0
  159. package/src/adapters/inbound/cli/commands/review.ts +18 -0
  160. package/src/adapters/inbound/cli/commands/status.ts +45 -0
  161. package/src/adapters/inbound/cli/commands.ts +36 -0
  162. package/src/adapters/inbound/cli/parse_command.ts +90 -0
  163. package/src/adapters/inbound/cli/run_cli.ts +50 -0
  164. package/src/adapters/outbound/git_repository.ts +57 -0
  165. package/src/adapters/outbound/node_project_files.ts +101 -0
  166. package/src/adapters/outbound/package_bundle.ts +56 -0
  167. package/src/adapters/outbound/process_agent_runner.ts +26 -0
  168. package/src/adapters/outbound/system_clock.ts +14 -0
  169. package/src/adapters/outbound/terminal_prompter.ts +22 -0
  170. package/src/app/ports/agent_runner.ts +11 -0
  171. package/src/app/ports/bundle.ts +22 -0
  172. package/src/app/ports/clock.ts +6 -0
  173. package/src/app/ports/project_files.ts +20 -0
  174. package/src/app/ports/prompter.ts +5 -0
  175. package/src/app/ports/repository.ts +13 -0
  176. package/src/app/project_context.ts +45 -0
  177. package/src/app/use_cases/abandon_work.ts +42 -0
  178. package/src/app/use_cases/approve_artifact.ts +146 -0
  179. package/src/app/use_cases/check_code.ts +90 -0
  180. package/src/app/use_cases/init_project.ts +59 -0
  181. package/src/app/use_cases/install_skills.ts +93 -0
  182. package/src/app/use_cases/journal_events.ts +48 -0
  183. package/src/app/use_cases/land_change.ts +113 -0
  184. package/src/app/use_cases/new_project_doc.ts +40 -0
  185. package/src/app/use_cases/new_work_item.ts +75 -0
  186. package/src/app/use_cases/project_status.ts +73 -0
  187. package/src/app/use_cases/record_progress.ts +101 -0
  188. package/src/app/use_cases/run_review.ts +71 -0
  189. package/src/app/use_cases/validate_project.ts +22 -0
  190. package/src/domain/agent_skill_homes.ts +25 -0
  191. package/src/domain/approvals/evaluate.ts +36 -0
  192. package/src/domain/approvals/fingerprint.ts +52 -0
  193. package/src/domain/approvals/records.ts +50 -0
  194. package/src/domain/architecture_sections.ts +23 -0
  195. package/src/domain/check/check_source.ts +64 -0
  196. package/src/domain/check/imports.ts +81 -0
  197. package/src/domain/check/rust_imports.ts +84 -0
  198. package/src/domain/check/type_count.ts +17 -0
  199. package/src/domain/config.ts +128 -0
  200. package/src/domain/cruze_error.ts +10 -0
  201. package/src/domain/edits/agents_file.ts +44 -0
  202. package/src/domain/edits/managed_tables.ts +60 -0
  203. package/src/domain/edits/progress_block.ts +33 -0
  204. package/src/domain/edits/status_lines.ts +52 -0
  205. package/src/domain/elements.ts +126 -0
  206. package/src/domain/glob.ts +46 -0
  207. package/src/domain/hashing.ts +52 -0
  208. package/src/domain/ids.ts +37 -0
  209. package/src/domain/journal.ts +62 -0
  210. package/src/domain/land/bookkeeping.ts +54 -0
  211. package/src/domain/land/merge_plan.ts +132 -0
  212. package/src/domain/land/text_ops.ts +66 -0
  213. package/src/domain/markdown.ts +79 -0
  214. package/src/domain/markdown_tables.ts +34 -0
  215. package/src/domain/project/artifact_ref.ts +42 -0
  216. package/src/domain/project/deltas.ts +49 -0
  217. package/src/domain/project/layout.ts +40 -0
  218. package/src/domain/project/living_model.ts +54 -0
  219. package/src/domain/project/new_items.ts +48 -0
  220. package/src/domain/project/plan_parts.ts +111 -0
  221. package/src/domain/project/progress.ts +43 -0
  222. package/src/domain/project/project_view.ts +72 -0
  223. package/src/domain/project/roadmap.ts +43 -0
  224. package/src/domain/project/work_items.ts +68 -0
  225. package/src/domain/project_name.ts +20 -0
  226. package/src/domain/scaffold.ts +29 -0
  227. package/src/domain/skill.ts +106 -0
  228. package/src/domain/skill_links.ts +103 -0
  229. package/src/domain/status/next_step.ts +119 -0
  230. package/src/domain/status/verification.ts +16 -0
  231. package/src/domain/status/work_status.ts +101 -0
  232. package/src/domain/trace.ts +44 -0
  233. package/src/domain/validation/delta_rules.ts +79 -0
  234. package/src/domain/validation/element_rules.ts +99 -0
  235. package/src/domain/validation/living_rules.ts +97 -0
  236. package/src/domain/validation/plan_rules.ts +105 -0
  237. package/src/domain/validation/problem.ts +21 -0
  238. package/src/domain/validation/project_rules.ts +99 -0
  239. package/src/domain/validation/validate_project.ts +31 -0
  240. package/src/domain/validation/work_item_rules.ts +144 -0
  241. package/src/main.ts +32 -0
  242. package/templates/init/AGENTS.md +28 -0
  243. package/templates/init/CHANGELOG.md +8 -0
  244. package/templates/init/CLAUDE.md +1 -0
  245. package/templates/init/README.md +1 -0
  246. package/templates/init/ci.yml +23 -0
  247. package/templates/init/config.yaml +33 -0
  248. package/templates/init/gitattributes +2 -0
@@ -0,0 +1,103 @@
1
+ import { posix } from "node:path";
2
+ import { SKILL_FILE, SKILL_PREFIX } from "./skill.js";
3
+
4
+ /**
5
+ * Pointers between bundled skill files. A skill reaches its own sibling files
6
+ * through relative Markdown links, and other Cruze skills through their
7
+ * installed path, `.agents/skills/cruze-<name>/<file>`.
8
+ */
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+ const INSTALLED_REF = /\.agents\/skills\/(cruze-[a-z0-9]+(?:-[a-z0-9]+)*)\/([\w./-]*\w)/g;
23
+ const MARKDOWN_LINK = /\]\(([^)\s]+)\)/g;
24
+
25
+ /** Every file of another Cruze skill that a text names by its installed path, in order, without repeats. */
26
+ export function installedSkillRefs(text ) {
27
+ const seen = new Set ();
28
+ const refs = [];
29
+ for (const match of text.matchAll(INSTALLED_REF)) {
30
+ const ref = { skill: match[1] ?? "", path: match[2] ?? "" };
31
+ const key = `${ref.skill}/${ref.path}`;
32
+ if (!seen.has(key)) refs.push(ref);
33
+ seen.add(key);
34
+ }
35
+ return refs;
36
+ }
37
+
38
+ /** Targets of the relative Markdown links outside code fences, without any `#fragment`. */
39
+ export function relativeLinks(text ) {
40
+ const targets = [];
41
+ let inFence = false;
42
+ for (const line of text.split("\n")) {
43
+ if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
44
+ if (inFence) continue;
45
+ for (const match of line.matchAll(MARKDOWN_LINK)) {
46
+ const target = (match[1] ?? "").split("#")[0] ?? "";
47
+ if (target !== "" && !/^[a-z][a-z0-9+.-]*:/i.test(target)) targets.push(target);
48
+ }
49
+ }
50
+ return targets;
51
+ }
52
+
53
+ /**
54
+ * Every broken pointer in the bundled skills: a link to a missing sibling, a link
55
+ * that leaves its skill, an installed path to a missing file, and a Markdown file
56
+ * that its skill's SKILL.md can't reach through links.
57
+ */
58
+ export function skillLinkProblems(skills ) {
59
+ const exists = new Set(skills.flatMap((skill) => skill.files.map((file) => `${skill.folder}/${file.path}`)));
60
+ const problems = [];
61
+ for (const skill of skills) {
62
+ for (const file of skill.files.filter((f) => f.path.endsWith(".md"))) {
63
+ const where = `${skill.folder}/${file.path}`;
64
+ for (const target of relativeLinks(file.text)) {
65
+ const resolved = resolveLink(file.path, target);
66
+ if (resolved === null) problems.push(`${where}: link ${target} leaves the skill; name another skill by its installed path`);
67
+ else if (!exists.has(`${skill.folder}/${resolved}`)) problems.push(`${where}: link ${target} points to a missing file`);
68
+ }
69
+ for (const ref of installedSkillRefs(file.text)) {
70
+ const folder = ref.skill.slice(SKILL_PREFIX.length);
71
+ if (!exists.has(`${folder}/${ref.path}`)) problems.push(`${where}: .agents/skills/${ref.skill}/${ref.path} is not in the bundle`);
72
+ }
73
+ }
74
+ const reachable = reachableFromSkillFile(skill);
75
+ for (const file of skill.files.filter((f) => f.path.endsWith(".md") && !reachable.has(f.path))) {
76
+ problems.push(`${skill.folder}/${file.path}: no link reaches it from ${SKILL_FILE}`);
77
+ }
78
+ }
79
+ return problems;
80
+ }
81
+
82
+ /** A link's path inside the skill folder, or null when it leaves the folder. */
83
+ function resolveLink(fromPath , target ) {
84
+ const resolved = posix.normalize(posix.join(posix.dirname(fromPath), target));
85
+ return resolved.startsWith("../") || resolved === ".." ? null : resolved;
86
+ }
87
+
88
+ function reachableFromSkillFile(skill ) {
89
+ const texts = new Map(skill.files.map((file) => [file.path, file.text]));
90
+ const reached = new Set ();
91
+ const queue = [SKILL_FILE];
92
+ while (queue.length > 0) {
93
+ const path = queue.shift() ?? "";
94
+ const text = texts.get(path);
95
+ if (reached.has(path) || text === undefined) continue;
96
+ reached.add(path);
97
+ for (const target of relativeLinks(text)) {
98
+ const resolved = resolveLink(path, target);
99
+ if (resolved !== null) queue.push(resolved);
100
+ }
101
+ }
102
+ return reached;
103
+ }
@@ -0,0 +1,119 @@
1
+ import { evaluateApproval } from "../approvals/evaluate.js";
2
+ import { PATHS } from "../project/layout.js";
3
+ import { isPlanned, readChangesTable } from "../project/plan_parts.js";
4
+ import { readProgress } from "../project/progress.js";
5
+
6
+ import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.js";
7
+ import { changesOf, featureOf, } from "../project/work_items.js";
8
+ import { isVerified } from "./verification.js";
9
+ import { activeChange } from "./work_status.js";
10
+
11
+ /** The Cruze step to run next, computed from the project's state and the current branch. */
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+ export function nextSteps(view , branch ) {
29
+ const project = projectStep(view);
30
+ if (project !== null) return { next: project, also: [] };
31
+ const active = activeChange(view, branch);
32
+ const inFlight = view.items.filter((item) => !item.archived && item.kind !== "change").flatMap((item) => itemSteps(view, item, branch));
33
+ const roadmap = roadmapStep(view);
34
+ const candidates = [...(active === undefined ? [] : [changeStep(view, active, branch)]), ...inFlight, ...(roadmap === null ? [] : [roadmap])];
35
+ const unique = candidates.filter((c, i) => candidates.findIndex((o) => o.step === c.step && o.target === c.target) === i);
36
+ const [next, ...also] = unique;
37
+ return { next: next ?? { step: "roadmap", reason: "every item on the roadmap has landed; close the release and plan the next" }, also };
38
+ }
39
+
40
+ /** The project documents come first: nothing else can proceed without them. */
41
+ function projectStep(view ) {
42
+ const docs = [
43
+ {
44
+ path: PATHS.vision,
45
+ missing: { step: "envision", reason: "the project has no vision yet" },
46
+ unapproved: { step: "envision", reason: "finish the vision and approve it" },
47
+ changed: { step: "envision", reason: "the vision changed since its approval; review the change and approve it again" },
48
+ },
49
+ {
50
+ path: PATHS.architecture,
51
+ missing: { step: "architect", reason: "the project has no architecture yet" },
52
+ unapproved: { step: "architect", reason: "finish the architecture, walk through it and approve it" },
53
+ changed: { step: "rethink", target: "architecture", reason: "the architecture changed since its approval" },
54
+ },
55
+ {
56
+ path: PATHS.roadmap,
57
+ missing: { step: "architect", reason: "the project has no roadmap yet" },
58
+ unapproved: { step: "roadmap", reason: "approve the release ordering" },
59
+ changed: { step: "roadmap", reason: "the roadmap changed since its approval; approve the new ordering" },
60
+ },
61
+ ];
62
+ for (const doc of docs) {
63
+ if (!view.snapshot.has(doc.path)) return doc.missing;
64
+ const state = evaluateApproval(view, doc.path).state;
65
+ if (state === "unapproved") return doc.unapproved;
66
+ if (state !== "approved") return doc.changed;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function itemSteps(view , item , branch ) {
72
+ if (item.kind === "standalone") return [changeStep(view, item, branch)];
73
+ const state = evaluateApproval(view, item.path);
74
+ if (readChangesTable(item.doc).length === 0) return [{ step: "architect", target: item.ref, reason: "the feature has no design or changes yet" }];
75
+ if (state.state === "unapproved") return [{ step: "architect", target: item.ref, reason: "review the feature's design, walk through it and approve it" }];
76
+ if (state.state !== "approved") return [{ step: "rethink", target: item.ref, reason: `the feature changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` }];
77
+ const changes = changesOf(view.items, item);
78
+ const landed = new Set(changes.filter((c) => readProgress(c.doc).landed !== undefined).map((c) => c.folderName));
79
+ const steps = [];
80
+ for (const row of readChangesTable(item.doc)) {
81
+ const change = changes.find((c) => c.folderName === row.change);
82
+ if (change !== undefined) {
83
+ if (!landed.has(row.change)) steps.push(changeStep(view, change, branch));
84
+ } else if (row.dependsOn.every((d) => landed.has(d))) {
85
+ steps.push({ step: "plan", target: `${item.ref}/${row.change}`, reason: "the next change of an approved feature, with its dependencies landed" });
86
+ }
87
+ }
88
+ return steps;
89
+ }
90
+
91
+ function changeStep(view , change , branch ) {
92
+ const feature = featureOf(view.items, change);
93
+ const featureState = feature === undefined ? undefined : evaluateApproval(view, feature.path);
94
+ if (feature !== undefined && featureState !== undefined && featureState.state !== "approved") {
95
+ const why = featureState.changed.length > 0 ? ` (${featureState.changed.join(", ")})` : "";
96
+ return { step: "rethink", target: feature.ref, reason: `the feature of ${change.ref} changed since its approval${why}` };
97
+ }
98
+ const bound = readProgress(change.doc).branch;
99
+ const state = evaluateApproval(view, change.path);
100
+ if (!isPlanned(change.doc)) return { step: "plan", target: change.ref, reason: "the change is designed but has no test plan or tasks yet" };
101
+ if (state.state === "unapproved") return { step: "plan", target: change.ref, reason: "review the plan and approve it on the branch that will build it" };
102
+ if (state.state !== "approved") return { step: "rethink", target: change.ref, reason: `the change or something it cites changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` };
103
+ if (bound !== undefined && bound !== branch) return { step: "switch-branch", target: bound, reason: `${change.ref} is built on branch ${bound}` };
104
+ const progress = readProgress(change.doc);
105
+ if ([...progress.tasks.values()].some((commit) => commit === null)) return { step: "build", target: change.ref, reason: "the change has open tasks" };
106
+ if (!isVerified(view.journal, change)) return { step: "verify", target: change.ref, reason: "every task is done; verify the change" };
107
+ return { step: "land", target: change.ref, reason: "the change is verified and accepted" };
108
+ }
109
+
110
+ /** The first roadmap item that hasn't started and whose blockers have all landed. */
111
+ function roadmapStep(view ) {
112
+ const text = view.snapshot.get(PATHS.roadmap);
113
+ if (text === undefined) return null;
114
+ const status = new Map(readRoadmapStatus(text).map((row) => [row.slug, row.state]));
115
+ const item = readRoadmapItems(text).find((i) => !status.has(i.slug) && i.blockedBy.every((b) => status.get(b) === "landed"));
116
+ if (item === undefined) return null;
117
+ if (item.kind === "feature") return { step: "envision", target: item.slug, reason: "the next roadmap feature; skip to architect when its requirements are settled" };
118
+ return { step: "architect", target: item.slug, reason: "the next roadmap change; design it as a tweak" };
119
+ }
@@ -0,0 +1,16 @@
1
+
2
+
3
+
4
+ /**
5
+ * Whether the user accepted a change at verify. The latest verification recorded for the change
6
+ * counts. A later approval keeps it only when the change's own design is unchanged, as when it is
7
+ * re-approved because something upstream moved; a changed plan means verifying again.
8
+ */
9
+ export function isVerified(journal , change ) {
10
+ const byTime = (a , b ) => a.at.localeCompare(b.at);
11
+ const verification = journal.filter((e) => e.event === "verification" && e["item"] === change.ref).sort(byTime).at(-1);
12
+ if (verification?.["result"] !== "accepted") return false;
13
+ const approvals = journal.filter((e) => e.event === "approve" && e["artifact"] === change.path && e["basis"] === undefined).sort(byTime);
14
+ const verifiedHash = approvals.filter((e) => e.at < verification.at).at(-1)?.["hash"];
15
+ return approvals.filter((e) => e.at > verification.at).every((e) => e["hash"] === verifiedHash);
16
+ }
@@ -0,0 +1,101 @@
1
+ import { evaluateApproval, } from "../approvals/evaluate.js";
2
+ import { findIds } from "../ids.js";
3
+ import { PATHS } from "../project/layout.js";
4
+ import { readDelta } from "../project/deltas.js";
5
+ import { readScope } from "../project/plan_parts.js";
6
+ import { readProgress } from "../project/progress.js";
7
+
8
+ import { changesOf, featureOf, } from "../project/work_items.js";
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+ export function projectStatus(view , branch ) {
37
+ const documents = [PATHS.vision, PATHS.architecture, PATHS.roadmap].filter((p) => view.snapshot.has(p)).map((p) => evaluateApproval(view, p));
38
+ const active = view.items.filter((item) => !item.archived);
39
+ const features = active
40
+ .filter((item) => item.kind === "feature")
41
+ .map((feature) => ({
42
+ ref: feature.ref,
43
+ path: feature.path,
44
+ approval: evaluateApproval(view, feature.path),
45
+ changes: changesOf(view.items, feature).map((change) => changeStatus(view, change)),
46
+ }));
47
+ const standalone = active.filter((item) => item.kind === "standalone").map((change) => changeStatus(view, change));
48
+ const status = { documents, features, standalone };
49
+ const bound = activeChange(view, branch);
50
+ if (bound !== undefined) status.active = bound.ref;
51
+ return status;
52
+ }
53
+
54
+ export function activeChange(view , branch ) {
55
+ if (branch === null) return undefined;
56
+ return view.items.find((item) => {
57
+ const progress = readProgress(item.doc);
58
+ return !item.archived && item.kind !== "feature" && progress.landed === undefined && progress.branch === branch;
59
+ });
60
+ }
61
+
62
+ function changeStatus(view , change ) {
63
+ const progress = readProgress(change.doc);
64
+ const status = {
65
+ ref: change.ref,
66
+ path: change.path,
67
+ approval: evaluateApproval(view, change.path),
68
+ tasksDone: [...progress.tasks.values()].filter((commit) => commit !== null).length,
69
+ tasksTotal: progress.tasks.size,
70
+ };
71
+ if (progress.branch !== undefined) status.branch = progress.branch;
72
+ if (progress.landed !== undefined) status.landed = progress.landed;
73
+ return status;
74
+ }
75
+
76
+ /** Why building may not start on this branch; empty when the build gate passes. */
77
+ export function buildGateReasons(view , branch ) {
78
+ const change = activeChange(view, branch);
79
+ if (change === undefined) return [`no change is bound to branch ${branch ?? "(none)"}; approve a planned change on this branch first`];
80
+ const required = [change.path];
81
+ const feature = featureOf(view.items, change);
82
+ if (feature !== undefined) required.push(feature.path);
83
+ if (view.snapshot.has(PATHS.architecture)) required.push(PATHS.architecture);
84
+ return required.flatMap((path) => {
85
+ const status = evaluateApproval(view, path);
86
+ if (status.state === "approved") return [];
87
+ const detail = status.changed.length > 0 ? `: ${status.changed.join(", ")} changed` : status.unjournaled === true ? ": the approval has no journal entry" : "";
88
+ return [`${path} is ${status.state}${detail}`];
89
+ });
90
+ }
91
+
92
+ /** The elements a change touches: its scope plus every ID its documents cite. */
93
+ export function touchedIds(view , change ) {
94
+ const feature = featureOf(view.items, change);
95
+ const scope = readScope(change.doc);
96
+ const ids = new Set([...(scope?.delivers ?? []), ...(scope?.builds ?? []), ...(scope?.removes ?? [])]);
97
+ const deltaDoc = feature?.doc ?? change.doc;
98
+ for (const id of readDelta(deltaDoc).ids) ids.add(id);
99
+ for (const id of findIds(change.doc.lines.join("\n"))) ids.add(id);
100
+ return new Set([...ids].filter((id) => !id.startsWith("RULE-")));
101
+ }
@@ -0,0 +1,44 @@
1
+ import { headingElements, statusOf } from "./elements.js";
2
+ import { findIds } from "./ids.js";
3
+ import { PATHS } from "./project/layout.js";
4
+
5
+ import { readScope } from "./project/plan_parts.js";
6
+ import { readDelta } from "./project/deltas.js";
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+ /**
18
+ * Links scenarios to the tests that prove them. For a change: every scenario it delivers.
19
+ * For the whole project: every built scenario in the living specs.
20
+ */
21
+ export function traceScenarios(view , tests , change ) {
22
+ const required = change === undefined ? builtScenarios(view) : (readScope(change.doc)?.delivers ?? []).filter((id) => id.startsWith("SCN-"));
23
+ const known = new Set([...view.living.elements.keys()]);
24
+ for (const item of view.items.filter((i) => !i.archived)) for (const id of readDelta(item.doc).ids) known.add(id);
25
+
26
+ const carriers = new Map ();
27
+ const unknown = [];
28
+ for (const [path, text] of tests) {
29
+ text.split("\n").forEach((line, index) => {
30
+ for (const id of findIds(line).filter((id) => id.startsWith("SCN-"))) {
31
+ carriers.set(id, [...new Set([...(carriers.get(id) ?? []), path])]);
32
+ if (!known.has(id)) unknown.push({ id, path, line: index + 1 });
33
+ }
34
+ });
35
+ }
36
+ const scenarios = required.map((id) => ({ id, tests: carriers.get(id) ?? [] }));
37
+ return { scenarios, missing: scenarios.filter((s) => s.tests.length === 0).map((s) => s.id), unknown };
38
+ }
39
+
40
+ function builtScenarios(view ) {
41
+ return [...view.living.docs]
42
+ .filter(([path]) => path.startsWith(`${PATHS.specsDir}/`))
43
+ .flatMap(([, doc]) => headingElements(doc).filter((e) => e.kind === "SCN" && statusOf(doc, e) === "built").map((e) => e.id));
44
+ }
@@ -0,0 +1,79 @@
1
+ import { ARCHITECTURE_KINDS } from "../architecture_sections.js";
2
+ import { scopeOf } from "../ids.js";
3
+ import { specPath } from "../project/layout.js";
4
+ import { orphanDeltaElements, readDelta, } from "../project/deltas.js";
5
+
6
+
7
+ import { elementProblems } from "./element_rules.js";
8
+ import { error, } from "./problem.js";
9
+
10
+ /** Merge records use this key for a spec file a delta created. */
11
+ export function capabilityKey(capability ) {
12
+ return `CAPABILITY-${capability}`;
13
+ }
14
+
15
+ /** Marks an element a landed change removed. */
16
+ export const REMOVED_MARK = "removed";
17
+
18
+ /** Rules for the spec and architecture deltas of a feature or standalone change. */
19
+ export function deltaProblems(view , item ) {
20
+ const { path, doc } = item;
21
+ const delta = readDelta(doc);
22
+ const merged = view.approvals.get(item.folder)?.merged ?? {};
23
+ const problems = [];
24
+
25
+ for (const orphan of orphanDeltaElements(doc)) {
26
+ problems.push(error(path, orphan.start, "delta-shape", `${orphan.id} must sit under an ADDED, MODIFIED or REMOVED requirement`));
27
+ }
28
+ for (const { element, section, scenarios } of delta.entries) {
29
+ const allowed = section === "spec" ? element.kind === "REQ" : ARCHITECTURE_KINDS.includes(element.kind);
30
+ if (!allowed) problems.push(error(path, element.start, "delta-shape", `${element.id} does not belong in the ${section} delta`));
31
+ problems.push(...elementProblems(path, doc, element));
32
+ for (const scenario of scenarios) {
33
+ problems.push(...elementProblems(path, doc, scenario));
34
+ if (scopeOf(scenario.id) !== scopeOf(element.id)) {
35
+ problems.push(error(path, scenario.start, "id-scope", `${scenario.id} must share its requirement's scope`));
36
+ }
37
+ problems.push(...opProblems(view, path, scenario.id, "ADDED", scenario.start, merged, element.op === "ADDED"));
38
+ }
39
+ problems.push(...opProblems(view, path, element.id, element.op ?? "ADDED", element.start, merged, false));
40
+ if (element.kind === "REQ" && element.op === "ADDED") problems.push(...capabilityProblems(view, item, delta, element.id, element.start));
41
+ }
42
+ for (const op of delta.capabilities) {
43
+ if (view.snapshot.has(specPath(op.capability)) && merged[capabilityKey(op.capability)] === undefined) {
44
+ problems.push(error(path, op.start, "capability-exists", `capability ${op.capability} already has ${specPath(op.capability)}`));
45
+ }
46
+ }
47
+ return problems;
48
+ }
49
+
50
+ function opProblems(
51
+ view ,
52
+ path ,
53
+ id ,
54
+ op ,
55
+ line ,
56
+ merged ,
57
+ underAddedRequirement ,
58
+ ) {
59
+ const exists = view.living.elements.has(id);
60
+ const mergedHere = merged[id] !== undefined;
61
+ if (view.retired.has(id)) return [error(path, line, "retired-id", `${id} was removed earlier and can never be reused`)];
62
+ if (op === "ADDED" && exists && !mergedHere) {
63
+ // A scenario under a MODIFIED requirement may already exist; only a new requirement's scenarios must be new.
64
+ if (!id.startsWith("SCN-") || underAddedRequirement) return [error(path, line, "delta-op", `ADDED ${id} already exists in the living docs`)];
65
+ }
66
+ if ((op === "MODIFIED" || op === "REMOVED") && !exists && merged[id] !== REMOVED_MARK) {
67
+ return [error(path, line, "delta-op", `${op} ${id} does not exist in the living docs`)];
68
+ }
69
+ return [];
70
+ }
71
+
72
+ function capabilityProblems(view , item , delta , id , line ) {
73
+ const capability = scopeOf(id);
74
+ if (capability === undefined) return [];
75
+ const hasFile = view.snapshot.has(specPath(capability));
76
+ const addsFile = delta.capabilities.some((op) => op.capability === capability);
77
+ if (hasFile || addsFile) return [];
78
+ return [error(item.path, line, "capability-missing", `${id} needs ${specPath(capability)}, or an "ADDED CAPABILITY ${capability}" operation`)];
79
+ }
@@ -0,0 +1,99 @@
1
+ import { factsOf, } from "../elements.js";
2
+ import { findIds, scopeOf, SCOPED_KINDS, } from "../ids.js";
3
+
4
+ import { error, } from "./problem.js";
5
+
6
+ /** Rules for a single element's body, shared by living documents and deltas. */
7
+
8
+ const REQUIRED_FACTS = {
9
+ CTX: ["Purpose"],
10
+ ENT: ["Kind", "Module", "File"],
11
+ UC: ["Input", "Output", "Errors", "Uses", "Module", "File"],
12
+ PORT: ["Direction", "Operations", "Module", "File"],
13
+ ADP: ["Technology", "Module", "File"],
14
+ FLOW: ["Elements"],
15
+ MOD: ["Path", "Layer"],
16
+ };
17
+
18
+ const ALLOWED_VALUES = {
19
+ Kind: { kinds: ["ENT"], values: ["aggregate", "entity", "value", "service", "policy", "event"] },
20
+ Direction: { kinds: ["PORT"], values: ["driven", "driving"] },
21
+ Layer: { kinds: ["MOD"], values: ["domain", "application", "adapter", "composition"] },
22
+ };
23
+
24
+ export function elementProblems(path , doc , element ) {
25
+ const problems = [];
26
+ if (SCOPED_KINDS.includes(element.kind) && scopeOf(element.id) === undefined) {
27
+ problems.push(error(path, element.start, "id-scope", `${element.id} needs a scope: ${element.kind}-<scope>.<name>`));
28
+ }
29
+ if (element.op === "REMOVED") return [...problems, ...removedProblems(path, doc, element)];
30
+
31
+ const facts = factsOf(doc, element);
32
+ for (const key of REQUIRED_FACTS[element.kind] ?? []) {
33
+ if (!facts.has(key)) problems.push(error(path, element.start, "required-fact", `${element.id} is missing "- ${key}:"`));
34
+ }
35
+ if (element.kind === "ADP" && !facts.has("Implements") && !facts.has("Drives")) {
36
+ problems.push(error(path, element.start, "required-fact", `${element.id} needs "- Implements:" or "- Drives:"`));
37
+ }
38
+ for (const [key, rule] of Object.entries(ALLOWED_VALUES)) {
39
+ const value = facts.get(key)?.[0];
40
+ if (rule.kinds.includes(element.kind) && value !== undefined && !rule.values.includes(value)) {
41
+ problems.push(error(path, element.start, "fact-value", `${element.id} ${key} must be one of ${rule.values.join(", ")}`));
42
+ }
43
+ }
44
+ const module = facts.get("Module")?.[0];
45
+ if (module !== undefined && !findIds(module).some((id) => id.startsWith("MOD-"))) {
46
+ problems.push(error(path, element.start, "fact-value", `${element.id} Module must name a MOD element`));
47
+ }
48
+ if (element.kind === "FLOW" && !bodyText(doc, element).includes("sequenceDiagram")) {
49
+ problems.push(error(path, element.start, "flow-diagram", `${element.id} needs a Mermaid sequenceDiagram`));
50
+ }
51
+ if (element.kind === "REQ") problems.push(...requirementProblems(path, doc, element));
52
+ if (element.kind === "SCN") problems.push(...scenarioProblems(path, doc, element));
53
+ return problems;
54
+ }
55
+
56
+ function removedProblems(path , doc , element ) {
57
+ const facts = factsOf(doc, element);
58
+ const problems = [];
59
+ if (!facts.has("Reason")) problems.push(error(path, element.start, "removed-reason", `REMOVED ${element.id} needs "- Reason:"`));
60
+ if (element.kind === "REQ" && !facts.has("Migration")) {
61
+ problems.push(error(path, element.start, "removed-reason", `REMOVED ${element.id} needs "- Migration:"`));
62
+ }
63
+ return problems;
64
+ }
65
+
66
+ function requirementProblems(path , doc , element ) {
67
+ const problems = [];
68
+ const ownBody = ownLines(doc, element).join("\n");
69
+ if (!/\b(SHALL|MUST)\b/.test(ownBody)) problems.push(error(path, element.start, "req-keyword", `${element.id} must state SHALL or MUST`));
70
+ const hasScenario = doc.headings.some((h) => h.line > element.start && h.line < element.end && h.level === element.level + 1 && h.text.startsWith("SCN-"));
71
+ if (!hasScenario) problems.push(error(path, element.start, "req-scenario", `${element.id} needs at least one scenario`));
72
+ return problems;
73
+ }
74
+
75
+ function scenarioProblems(path , doc , element ) {
76
+ const steps = ownLines(doc, element)
77
+ .map((line) => /^- (GIVEN|WHEN|THEN|AND) /.exec(line)?.[1])
78
+ .filter((step) => step !== undefined);
79
+ const order = steps.filter((step) => step !== "AND").join(" ");
80
+ if (!/^(GIVEN )*WHEN( THEN)+$/.test(order)) {
81
+ return [error(path, element.start, "scenario-steps", `${element.id} needs GIVEN steps (optional), one WHEN, then one or more THEN`)];
82
+ }
83
+ if (steps[0] === "AND") return [error(path, element.start, "scenario-steps", `${element.id} cannot start with AND`)];
84
+ return [];
85
+ }
86
+
87
+ /** The element's lines before its first nested heading, without the heading itself. */
88
+ function ownLines(doc , element ) {
89
+ const lines = [];
90
+ for (let i = element.start + 1; i < element.end; i++) {
91
+ if (!doc.inFence[i] && /^#{1,6} /.test(doc.lines[i] ?? "")) break;
92
+ lines.push(doc.lines[i] ?? "");
93
+ }
94
+ return lines;
95
+ }
96
+
97
+ function bodyText(doc , element ) {
98
+ return doc.lines.slice(element.start, element.end).join("\n");
99
+ }
@@ -0,0 +1,97 @@
1
+ import { ARCHITECTURE_KINDS, sectionForKind } from "../architecture_sections.js";
2
+ import { headingElements, statusOf, } from "../elements.js";
3
+ import { findIds, scopeOf, STATUS_KINDS } from "../ids.js";
4
+
5
+ import { PATHS } from "../project/layout.js";
6
+
7
+ import { elementProblems } from "./element_rules.js";
8
+ import { error, warning, } from "./problem.js";
9
+
10
+ /** Rules for the living docs: vision, architecture, specs, ADRs and the IDs they cite. */
11
+ export function livingProblems(view ) {
12
+ const problems = [];
13
+ for (const { id, places } of view.living.duplicates) {
14
+ problems.push(error(places[0]?.split(":")[0] ?? "", undefined, "duplicate-id", `${id} is defined more than once: ${places.join(", ")}`));
15
+ }
16
+ for (const [path, doc] of view.living.docs) {
17
+ problems.push(...malformedIdHeadings(path, doc));
18
+ if (path === PATHS.architecture) problems.push(...architectureProblems(path, doc, everApproved(view, path)));
19
+ else if (path.startsWith(`${PATHS.specsDir}/`)) problems.push(...specProblems(path, doc));
20
+ else if (path.startsWith(`${PATHS.adrDir}/`)) problems.push(...adrProblems(path, doc));
21
+ }
22
+ for (const path of [...view.living.docs.keys(), PATHS.roadmap]) {
23
+ const text = view.snapshot.get(path);
24
+ if (text === undefined) continue;
25
+ for (const id of findIds(text)) {
26
+ if (!view.living.elements.has(id)) problems.push(error(path, lineOf(text, id), "unknown-id", `${id} is cited but not defined in the living docs`));
27
+ }
28
+ }
29
+ return problems;
30
+ }
31
+
32
+ /** A heading shaped like `<KIND>-...: Title` whose ID breaks the grammar. */
33
+ export function malformedIdHeadings(path , doc ) {
34
+ return doc.headings.flatMap((heading) => {
35
+ const match = /^(?:(?:ADDED|MODIFIED|REMOVED) )?([A-Z]{2,5}-\S*):\s/.exec(heading.text);
36
+ if (match?.[1] === undefined || findIds(match[1])[0] === match[1]) return [];
37
+ return [error(path, heading.line, "malformed-id", `"${match[1]}" is not a valid ID`)];
38
+ });
39
+ }
40
+
41
+ /** Statuses are stamped by the first approval, so their absence before it is expected. */
42
+ function everApproved(view , path ) {
43
+ return [...view.approvals.values()].some((file) => file.approvals.some((record) => record.artifact === path));
44
+ }
45
+
46
+ function architectureProblems(path , doc , approved ) {
47
+ const problems = [];
48
+ for (const element of headingElements(doc)) {
49
+ const section = [...doc.headings].reverse().find((h) => h.level === 2 && h.line < element.start);
50
+ if (!ARCHITECTURE_KINDS.includes(element.kind)) {
51
+ problems.push(error(path, element.start, "misplaced-element", `${element.id} does not belong in the architecture`));
52
+ } else if (element.kind !== "VIEW" && section?.text !== sectionForKind(element.kind)) {
53
+ problems.push(error(path, element.start, "misplaced-element", `${element.id} belongs under "## ${sectionForKind(element.kind)}"`));
54
+ }
55
+ problems.push(...elementProblems(path, doc, element), ...(approved ? statusProblems(path, doc, element) : []));
56
+ }
57
+ return problems;
58
+ }
59
+
60
+ function specProblems(path , doc ) {
61
+ const capability = path.slice(path.lastIndexOf("/") + 1, -".md".length);
62
+ const problems = [];
63
+ const elements = headingElements(doc);
64
+ for (const element of elements) {
65
+ const expectedLevel = element.kind === "REQ" ? 2 : element.kind === "SCN" ? 3 : 0;
66
+ if (expectedLevel === 0) problems.push(error(path, element.start, "misplaced-element", `${element.id} does not belong in a spec`));
67
+ else if (element.level !== expectedLevel) problems.push(error(path, element.start, "heading-level", `${element.id} must be a level ${expectedLevel} heading`));
68
+ if (scopeOf(element.id) !== undefined && scopeOf(element.id) !== capability) {
69
+ problems.push(error(path, element.start, "id-scope", `${element.id} must use the scope "${capability}", the capability of this file`));
70
+ }
71
+ problems.push(...elementProblems(path, doc, element), ...statusProblems(path, doc, element));
72
+ }
73
+ return problems;
74
+ }
75
+
76
+ function adrProblems(path , doc ) {
77
+ const text = doc.lines.join("\n");
78
+ return ["Status", "Date"]
79
+ .filter((key) => !new RegExp(`^- ${key}: `, "m").test(text))
80
+ .map((key) => error(path, undefined, "adr-fact", `the ADR is missing "- ${key}:"`));
81
+ }
82
+
83
+ function statusProblems(path , doc , element ) {
84
+ const status = statusOf(doc, element);
85
+ if (STATUS_KINDS.includes(element.kind) && status === undefined) {
86
+ return [warning(path, element.start, "missing-status", `${element.id} has no status yet; approving the document adds it`)];
87
+ }
88
+ if (!STATUS_KINDS.includes(element.kind) && status !== undefined) {
89
+ return [error(path, element.start, "unexpected-status", `${element.id} is not a kind that carries a status`)];
90
+ }
91
+ return [];
92
+ }
93
+
94
+ export function lineOf(text , needle ) {
95
+ const index = text.indexOf(needle);
96
+ return index === -1 ? undefined : text.slice(0, index).split("\n").length - 1;
97
+ }