@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,90 @@
1
+ import { factsOf } from "../../domain/elements.ts";
2
+ import { checkSource, type CheckFinding } from "../../domain/check/check_source.ts";
3
+ import { CruzeError } from "../../domain/cruze_error.ts";
4
+ import { matchesAny } from "../../domain/glob.ts";
5
+ import type { CruzeConfig } from "../../domain/config.ts";
6
+ import type { ProjectView } from "../../domain/project/project_view.ts";
7
+ import { resolveChange } from "../../domain/project/artifact_ref.ts";
8
+ import { activeChange } from "../../domain/status/work_status.ts";
9
+ import { traceScenarios, type TraceReport } from "../../domain/trace.ts";
10
+ import { loadView, type ProjectDeps } from "../project_context.ts";
11
+ import type { ProjectFiles } from "../ports/project_files.ts";
12
+
13
+ export interface CheckReport {
14
+ passed: boolean;
15
+ checked: number;
16
+ findings: CheckFinding[];
17
+ }
18
+
19
+ /**
20
+ * Enforces layer rules and file budgets. Layer violations always fail; budget and
21
+ * module-map warnings fail only with `ci`.
22
+ */
23
+ export async function checkCode(deps: ProjectDeps, options: { files?: string[]; ci: boolean }): Promise<CheckReport> {
24
+ const view = await loadView(deps.files);
25
+ const config = requireConfig(view);
26
+ const sources = await readMatching(deps.files, config.source);
27
+ for (const file of options.files ?? []) {
28
+ if (!sources.has(file)) {
29
+ const text = await deps.files.readText(file);
30
+ if (text === undefined) throw new CruzeError("not-found", `${file} does not exist`);
31
+ if (matchesAny(file, config.source)) sources.set(file, text);
32
+ }
33
+ }
34
+ const only = options.files?.filter((file) => sources.has(file));
35
+ const findings = checkSource({ config, sources, modulePaths: modulePaths(view), ...(only === undefined ? {} : { only }) });
36
+ const failing = findings.filter((f) => f.severity === "error" || options.ci);
37
+ return { passed: failing.length === 0, checked: only?.length ?? sources.size, findings };
38
+ }
39
+
40
+ export interface TraceResult extends TraceReport {
41
+ passed: boolean;
42
+ scope: string;
43
+ }
44
+
45
+ /** Scenario-to-test traceability for this branch's change, or for every built scenario with `all`. */
46
+ export async function traceTests(deps: ProjectDeps, options: { all: boolean; change?: string }): Promise<TraceResult> {
47
+ const view = await loadView(deps.files);
48
+ const config = requireConfig(view);
49
+ let change;
50
+ if (!options.all) {
51
+ change = options.change !== undefined ? resolveChange(view, options.change) : activeChange(view, await deps.repository.currentBranch());
52
+ if (change === undefined) throw new CruzeError("no-active-change", "no change is bound to this branch; name one with --change, or use --all");
53
+ }
54
+ const report = traceScenarios(view, await readMatching(deps.files, config.tests), change);
55
+ return { ...report, passed: report.missing.length === 0 && report.unknown.length === 0, scope: change?.ref ?? "all built scenarios" };
56
+ }
57
+
58
+ function requireConfig(view: ProjectView): CruzeConfig {
59
+ const config = view.config?.config;
60
+ if (config === null || config === undefined) throw new CruzeError("config", "fix .cruze/config.yaml first; run cruze validate for details");
61
+ return config;
62
+ }
63
+
64
+ /** Files matching any glob, read from the smallest directories that can contain them. */
65
+ async function readMatching(files: ProjectFiles, globs: string[]): Promise<Map<string, string>> {
66
+ const result = new Map<string, string>();
67
+ const bases = [...new Set(globs.map(globBase))];
68
+ for (const base of bases) {
69
+ const listed = await files.listFiles(base === "" ? "." : base);
70
+ for (const path of listed) {
71
+ if (result.has(path) || !matchesAny(path, globs)) continue;
72
+ const text = await files.readText(path);
73
+ if (text !== undefined) result.set(path, text);
74
+ }
75
+ }
76
+ return result;
77
+ }
78
+
79
+ /** The directory part of a glob before its first wildcard. */
80
+ function globBase(glob: string): string {
81
+ const segments = glob.split("/");
82
+ const fixed = segments.slice(0, segments.findIndex((s) => /[*?{]/.test(s)));
83
+ return fixed.join("/");
84
+ }
85
+
86
+ function modulePaths(view: ProjectView): string[] {
87
+ return [...view.living.elements.values()]
88
+ .filter((e) => e.id.startsWith("MOD-"))
89
+ .flatMap((e) => (factsOf(e.doc, e.element).get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")));
90
+ }
@@ -0,0 +1,59 @@
1
+ import { parseProjectName } from "../../domain/project_name.ts";
2
+ import { INIT_SCAFFOLD, renderTemplate } from "../../domain/scaffold.ts";
3
+ import type { Bundle } from "../ports/bundle.ts";
4
+ import type { ProjectFiles } from "../ports/project_files.ts";
5
+ import type { Prompter } from "../ports/prompter.ts";
6
+ import { installSkills, type InstallSkillsReport } from "./install_skills.ts";
7
+
8
+ export interface InitProjectRequest {
9
+ /** The project name; asked for when absent. */
10
+ name?: string;
11
+ /** Offered as the default answer when asking for the name. */
12
+ defaultName: string;
13
+ agents: string[];
14
+ /**
15
+ * How CI runs this version of Cruze through npx, such as `github:PrintPractical/cruze#v0.0.1`
16
+ * for a git install. Defaults to the published npm package at this version.
17
+ */
18
+ package?: string;
19
+ }
20
+
21
+ export interface InitProjectReport {
22
+ project: string;
23
+ created: string[];
24
+ /** Files that already existed and were left untouched. */
25
+ skipped: string[];
26
+ skills: InstallSkillsReport;
27
+ }
28
+
29
+ /**
30
+ * Gives a repository the Cruze starting files and installs the skills. Existing
31
+ * files are never overwritten, so running it in a brownfield repo is safe.
32
+ */
33
+ export async function initProject(
34
+ deps: { files: ProjectFiles; bundle: Bundle; prompter: Prompter },
35
+ request: InitProjectRequest,
36
+ ): Promise<InitProjectReport> {
37
+ const { files, bundle, prompter } = deps;
38
+ const project = parseProjectName(request.name ?? (await prompter.ask("Project name", request.defaultName)));
39
+ const values = {
40
+ project_name: project,
41
+ project_name_quoted: JSON.stringify(project),
42
+ cruze_version: bundle.version,
43
+ cruze_package: request.package ?? `@printpractical/cruze@${bundle.version}`,
44
+ };
45
+
46
+ const created: string[] = [];
47
+ const skipped: string[] = [];
48
+ for (const entry of INIT_SCAFFOLD) {
49
+ if (await files.exists(entry.path)) {
50
+ skipped.push(entry.path);
51
+ continue;
52
+ }
53
+ await files.writeText(entry.path, renderTemplate(await bundle.template(entry.template), values));
54
+ created.push(entry.path);
55
+ }
56
+
57
+ const skills = await installSkills({ files, bundle }, { agents: request.agents });
58
+ return { project, created, skipped, skills };
59
+ }
@@ -0,0 +1,93 @@
1
+ import { posix } from "node:path";
2
+ import { CruzeError } from "../../domain/cruze_error.ts";
3
+ import { AGENT_SKILL_HOMES, SHARED_SKILLS_DIR, agentSkillHome, knownAgents } from "../../domain/agent_skill_homes.ts";
4
+ import { SKILL_FILE, installedSkillName, isInstalledSkillName, skillProblems } from "../../domain/skill.ts";
5
+ import type { Bundle, BundledSkill } from "../ports/bundle.ts";
6
+ import type { ProjectFiles } from "../ports/project_files.ts";
7
+
8
+ export interface InstallSkillsRequest {
9
+ /** Agents to link skills for even when the project shows no sign of using them. */
10
+ agents: string[];
11
+ }
12
+
13
+ export interface InstallSkillsReport {
14
+ version: string;
15
+ installed: string[];
16
+ removed: string[];
17
+ linked: Array<{ agent: string; dir: string }>;
18
+ }
19
+
20
+ /**
21
+ * Replaces the project's Cruze skills with the bundled set. Skills are written to
22
+ * the shared Agent Skills directory, and linked for every agent the project uses.
23
+ * Skills without the Cruze prefix belong to the user and are never touched.
24
+ */
25
+ export async function installSkills(
26
+ deps: { files: ProjectFiles; bundle: Bundle },
27
+ request: InstallSkillsRequest,
28
+ ): Promise<InstallSkillsReport> {
29
+ const { files, bundle } = deps;
30
+ const skills = await bundle.skills();
31
+ assertValid(skills);
32
+
33
+ const names = skills.map((skill) => installedSkillName(skill.folder));
34
+ const removed = await removeStaleSkills(files, SHARED_SKILLS_DIR, names);
35
+ for (const skill of skills) {
36
+ await writeSkill(files, skill);
37
+ }
38
+
39
+ const linked: InstallSkillsReport["linked"] = [];
40
+ for (const agent of await agentsToLink(files, request.agents)) {
41
+ const home = agentSkillHome(agent);
42
+ if (home === undefined) continue;
43
+ await removeStaleSkills(files, home.skillsDir, []);
44
+ for (const name of names) {
45
+ const linkPath = posix.join(home.skillsDir, name);
46
+ await files.link(linkPath, posix.relative(home.skillsDir, posix.join(SHARED_SKILLS_DIR, name)));
47
+ }
48
+ linked.push({ agent, dir: home.skillsDir });
49
+ }
50
+
51
+ return { version: bundle.version, installed: names, removed, linked };
52
+ }
53
+
54
+ function assertValid(skills: BundledSkill[]): void {
55
+ const problems = skills.flatMap((skill) => {
56
+ const skillFile = skill.files.find((file) => file.path === SKILL_FILE);
57
+ return skillFile === undefined ? [`${skill.folder}: ${SKILL_FILE} is missing`] : skillProblems(skill.folder, skillFile.text);
58
+ });
59
+ if (problems.length > 0) {
60
+ throw new CruzeError("invalid-bundle", `The bundled skills are invalid:\n${problems.join("\n")}`);
61
+ }
62
+ }
63
+
64
+ async function writeSkill(files: ProjectFiles, skill: BundledSkill): Promise<void> {
65
+ const dir = posix.join(SHARED_SKILLS_DIR, installedSkillName(skill.folder));
66
+ await files.remove(dir);
67
+ for (const file of skill.files) {
68
+ await files.writeText(posix.join(dir, file.path), file.text);
69
+ }
70
+ }
71
+
72
+ /** Removes Cruze skills in `dir` that are not in `keep`, returning their names. */
73
+ async function removeStaleSkills(files: ProjectFiles, dir: string, keep: string[]): Promise<string[]> {
74
+ const stale = (await files.listEntries(dir)).filter((name) => isInstalledSkillName(name) && !keep.includes(name));
75
+ for (const name of stale) {
76
+ await files.remove(posix.join(dir, name));
77
+ }
78
+ return stale;
79
+ }
80
+
81
+ async function agentsToLink(files: ProjectFiles, requested: string[]): Promise<string[]> {
82
+ const unknown = requested.filter((agent) => agentSkillHome(agent) === undefined);
83
+ if (unknown.length > 0) {
84
+ throw new CruzeError("unknown-agent", `Unknown agent: ${unknown.join(", ")}. Known agents: ${knownAgents().join(", ")}.`);
85
+ }
86
+ const agents = new Set(requested);
87
+ for (const home of AGENT_SKILL_HOMES) {
88
+ for (const marker of home.markers) {
89
+ if (await files.exists(marker)) agents.add(home.agent);
90
+ }
91
+ }
92
+ return [...agents];
93
+ }
@@ -0,0 +1,48 @@
1
+ import { CruzeError } from "../../domain/cruze_error.ts";
2
+ import { recordedEventProblems, type JournalEntry } from "../../domain/journal.ts";
3
+ import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
4
+ import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
5
+
6
+ /** Records an event a skill observed: a rethink, a review round, a disposition, an override or a bug. */
7
+ export async function recordEvent(deps: ProjectDeps, event: string, fields: Record<string, string>, item?: string): Promise<JournalEntry> {
8
+ const problems = recordedEventProblems(event, fields);
9
+ if (problems.length > 0) throw new CruzeError("invalid-event", problems.join("; "));
10
+ let folder = ".cruze";
11
+ if (item !== undefined) {
12
+ const resolved = resolveArtifact(await loadView(deps.files), item).item;
13
+ if (resolved === undefined) throw new CruzeError("not-found", `"${item}" is not a feature or change`);
14
+ folder = resolved.folder;
15
+ fields = { ...fields, item: resolved.ref };
16
+ }
17
+ return appendJournal(deps, folder, event, { ...fields, cruze: deps.bundle.version });
18
+ }
19
+
20
+ export async function listEvents(deps: ProjectDeps, event?: string): Promise<JournalEntry[]> {
21
+ const { journal } = await loadView(deps.files);
22
+ return event === undefined ? journal : journal.filter((entry) => entry.event === event);
23
+ }
24
+
25
+ export interface FeedbackExport {
26
+ format: "cruze-feedback/1";
27
+ cruze: string;
28
+ exported_at: string;
29
+ redacted: boolean;
30
+ entries: JournalEntry[];
31
+ }
32
+
33
+ /**
34
+ * Bundles the journal for the framework feedback loop. By default the project name and
35
+ * the approver's identity are redacted, since the bundle may leave the project.
36
+ */
37
+ export async function exportFeedback(deps: ProjectDeps, redact: boolean): Promise<FeedbackExport> {
38
+ const view = await loadView(deps.files);
39
+ const project = view.config?.config?.project ?? "";
40
+ const scrub = (value: unknown): unknown => {
41
+ if (typeof value === "string") return project === "" ? value : value.split(project).join("<project>");
42
+ if (Array.isArray(value)) return value.map(scrub);
43
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrub(v)]));
44
+ return value;
45
+ };
46
+ const entries = redact ? view.journal.map((entry) => ({ ...(scrub(entry) as JournalEntry), by: "<redacted>" })) : view.journal;
47
+ return { format: "cruze-feedback/1", cruze: deps.bundle.version, exported_at: deps.clock.now().toISOString(), redacted: redact, entries };
48
+ }
@@ -0,0 +1,113 @@
1
+ import { evaluateApproval } from "../../domain/approvals/evaluate.ts";
2
+ import { fingerprint } from "../../domain/approvals/fingerprint.ts";
3
+ import { serializeApprovals, withApproval, type ApprovalsFile } from "../../domain/approvals/records.ts";
4
+ import { CruzeError } from "../../domain/cruze_error.ts";
5
+ import { renderAgentsFile } from "../../domain/edits/agents_file.ts";
6
+ import { applyBookkeeping } from "../../domain/land/bookkeeping.ts";
7
+ import { planMerge } from "../../domain/land/merge_plan.ts";
8
+ import { parseMarkdown } from "../../domain/markdown.ts";
9
+ import { resolveChange } from "../../domain/project/artifact_ref.ts";
10
+ import { PATHS } from "../../domain/project/layout.ts";
11
+ import { readTasks } from "../../domain/project/plan_parts.ts";
12
+ import { readProgress } from "../../domain/project/progress.ts";
13
+ import { approvalFolder, approvalsPath, type ProjectView } from "../../domain/project/project_view.ts";
14
+ import { changesOf, featureOf, type WorkItem } from "../../domain/project/work_items.ts";
15
+ import { isVerified } from "../../domain/status/verification.ts";
16
+ import { activeChange } from "../../domain/status/work_status.ts";
17
+ import { validateProject } from "../../domain/validation/validate_project.ts";
18
+ import { appendJournal, loadView, withFiles, type ProjectDeps } from "../project_context.ts";
19
+
20
+ const AGENTS_FILE = "AGENTS.md";
21
+
22
+ export interface LandReport {
23
+ change: string;
24
+ merged: string[];
25
+ built: string[];
26
+ removed: string[];
27
+ restamped: string[];
28
+ finished: boolean;
29
+ archivedTo?: string;
30
+ }
31
+
32
+ /**
33
+ * Lands a finished change: merges its work into the living docs, re-stamps the approvals the
34
+ * merge would otherwise make stale, records the land, and archives the work once it is finished.
35
+ * Nothing is written unless the merged docs still validate.
36
+ */
37
+ export async function landChange(deps: ProjectDeps, ref?: string, options: { override?: string } = {}): Promise<LandReport> {
38
+ const view = await loadView(deps.files);
39
+ const change = ref !== undefined ? resolveChange(view, ref) : activeChange(view, await deps.repository.currentBranch());
40
+ if (change === undefined) throw new CruzeError("no-active-change", "no change is bound to this branch; name one to land");
41
+ const source = change.kind === "change" ? featureOf(view.items, change) : change;
42
+ if (source === undefined) throw new CruzeError("orphan-change", `${change.ref} has no feature.md`);
43
+ requireReady(view, change, source);
44
+ const verified = isVerified(view.journal, change);
45
+ if (!verified && (options.override ?? "").trim() === "") {
46
+ throw new CruzeError("not-verified", `${change.ref} has no accepted verification; run verify, or land with --override <reason>`);
47
+ }
48
+
49
+ const restamp = [PATHS.architecture, source.path, ...(source === change ? [] : changesOf(view.items, source).map((c) => c.path))]
50
+ .filter((path, i, all) => all.indexOf(path) === i && view.snapshot.has(path) && evaluateApproval(view, path).state === "approved");
51
+
52
+ const plan = planMerge(view, change, source);
53
+ const commit = await deps.repository.headCommit();
54
+ const landed = `${deps.clock.today()}${commit === null ? "" : ` (${commit})`}`;
55
+ const books = applyBookkeeping(view, change, source, landed, plan.files);
56
+ const merged = withFiles(view, plan.files);
57
+ requireStillValid(view, merged);
58
+
59
+ const approvals = new Map<string, ApprovalsFile>();
60
+ const by = (await deps.repository.userName()) ?? "unknown";
61
+ for (const path of restamp) {
62
+ const folder = approvalFolder(merged, path);
63
+ const { hash, upstream } = fingerprint(merged, path, parseMarkdown(merged.snapshot.get(path) ?? ""));
64
+ const file = approvals.get(folder) ?? merged.approvals.get(folder) ?? { version: 1, approvals: [], merged: {} };
65
+ approvals.set(folder, withApproval(file, { artifact: path, hash, upstream, approvedAt: deps.clock.now().toISOString(), by, basis: `land ${change.ref}` }));
66
+ }
67
+ const sourceApprovals = approvals.get(source.folder) ?? merged.approvals.get(source.folder) ?? { version: 1, approvals: [], merged: {} };
68
+ approvals.set(source.folder, { ...sourceApprovals, merged: plan.merged });
69
+
70
+ for (const [path, text] of plan.files) await deps.files.writeText(path, text);
71
+ for (const [folder, file] of approvals) await deps.files.writeText(approvalsPath(folder), serializeApprovals(file));
72
+ for (const path of restamp) {
73
+ const record = approvals.get(approvalFolder(merged, path))?.approvals.find((r) => r.artifact === path);
74
+ await appendJournal(deps, approvalFolder(merged, path), "approve", { artifact: path, hash: record?.hash, basis: `land ${change.ref}` });
75
+ }
76
+ if (!verified) await appendJournal(deps, source.folder, "override", { gate: "land", reason: options.override, item: change.ref });
77
+ await appendJournal(deps, source.folder, "land", { change: change.ref, merged: plan.mergedIds, built: plan.built, removed: plan.removed, release: books.release, commit });
78
+ if (books.archive !== undefined) await deps.files.move(books.archive.from, books.archive.to);
79
+ const agents = await deps.files.readText(AGENTS_FILE);
80
+ if (agents !== undefined) {
81
+ const rendered = renderAgentsFile(agents, merged);
82
+ if (rendered !== agents) await deps.files.writeText(AGENTS_FILE, rendered);
83
+ }
84
+
85
+ const report: LandReport = { change: change.ref, merged: plan.mergedIds, built: plan.built, removed: plan.removed, restamped: restamp, finished: books.finished };
86
+ if (books.archive !== undefined) report.archivedTo = books.archive.to;
87
+ return report;
88
+ }
89
+
90
+ /** The change, its feature and the architecture are approved and current, valid, and every task is done. */
91
+ function requireReady(view: ProjectView, change: WorkItem, source: WorkItem): void {
92
+ const reasons: string[] = [];
93
+ for (const path of [change.path, source.path, PATHS.architecture].filter((p, i, all) => all.indexOf(p) === i && view.snapshot.has(p))) {
94
+ const status = evaluateApproval(view, path);
95
+ if (status.state !== "approved") reasons.push(`${path} is ${status.state}${status.changed.length > 0 ? ` (${status.changed.join(", ")})` : ""}`);
96
+ }
97
+ const errors = validateProject(view).filter((p) => p.severity === "error" && (p.path === change.path || p.path === source.path));
98
+ reasons.push(...errors.map((p) => `${p.path}:${p.line ?? ""} ${p.message}`));
99
+ const progress = readProgress(change.doc);
100
+ const open = readTasks(change.doc).tasks.filter((task) => (progress.tasks.get(task.number) ?? null) === null).map((task) => task.number);
101
+ if (open.length > 0) reasons.push(`tasks not done: ${open.join(", ")}`);
102
+ if (reasons.length > 0) throw new CruzeError("not-ready", `${change.ref} cannot land yet:\n ${reasons.join("\n ")}`);
103
+ }
104
+
105
+ /** The merge may not introduce errors into the living docs. */
106
+ function requireStillValid(before: ProjectView, after: ProjectView): void {
107
+ const key = (p: { path: string; message: string }): string => `${p.path}|${p.message}`;
108
+ const existing = new Set(validateProject(before).map(key));
109
+ const introduced = validateProject(after).filter((p) => p.severity === "error" && p.path.startsWith("docs/") && !existing.has(key(p)));
110
+ if (introduced.length > 0) {
111
+ throw new CruzeError("merge-invalid", `landing would break the living docs; nothing was written:\n ${introduced.map((p) => `${p.path}:${p.line ?? ""} ${p.message}`).join("\n ")}`);
112
+ }
113
+ }
@@ -0,0 +1,40 @@
1
+ import { CruzeError } from "../../domain/cruze_error.ts";
2
+ import { PATHS } from "../../domain/project/layout.ts";
3
+ import { requireSlug } from "../../domain/project/new_items.ts";
4
+ import { renderTemplate } from "../../domain/scaffold.ts";
5
+ import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
6
+
7
+ export type ProjectDocKind = "vision" | "glossary" | "architecture" | "roadmap";
8
+
9
+ export const PROJECT_DOC_KINDS: readonly ProjectDocKind[] = ["vision", "glossary", "architecture", "roadmap"];
10
+
11
+ export interface NewDocReport {
12
+ kind: ProjectDocKind | "note";
13
+ path: string;
14
+ }
15
+
16
+ /** Creates one of the project's living documents from its template, named for the project. */
17
+ export async function createProjectDoc(deps: ProjectDeps, kind: ProjectDocKind): Promise<NewDocReport> {
18
+ const view = await loadView(deps.files);
19
+ const path = PATHS[kind];
20
+ if (view.snapshot.has(path)) throw new CruzeError("exists", `${path} already exists`);
21
+ const project = view.config?.config?.project ?? "";
22
+ if (project === "") throw new CruzeError("no-config", `${PATHS.config} is missing or has no project name; run cruze init first`);
23
+ const values = { project_name: project, id: "", title: "", date: deps.clock.today() };
24
+ await deps.files.writeText(path, renderTemplate(await deps.bundle.formatTemplate(`${kind}.md`), values));
25
+ await appendJournal(deps, ".cruze", "new", { kind, ref: path });
26
+ return { kind, path };
27
+ }
28
+
29
+ /** Creates a dated note in `.cruze/notes/`, such as a captured explore session or research evidence. */
30
+ export async function createNote(deps: ProjectDeps, slug: string, title: string): Promise<NewDocReport> {
31
+ requireSlug(slug);
32
+ if (title.trim() === "") throw new CruzeError("missing-title", "a title is required");
33
+ const view = await loadView(deps.files);
34
+ const date = deps.clock.today();
35
+ const base = `${PATHS.notesDir}/${date}-${slug}`;
36
+ let path = `${base}.md`;
37
+ for (let n = 2; view.snapshot.has(path); n++) path = `${base}-${n}.md`;
38
+ await deps.files.writeText(path, `# ${title}\n\n- Date: ${date}\n\n`);
39
+ return { kind: "note", path };
40
+ }
@@ -0,0 +1,75 @@
1
+ import { CruzeError } from "../../domain/cruze_error.ts";
2
+ import { upsertRow } from "../../domain/edits/managed_tables.ts";
3
+ import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
4
+ import { PATHS } from "../../domain/project/layout.ts";
5
+ import { allocateId, changeRow, fillTemplate, requireSlug } from "../../domain/project/new_items.ts";
6
+ import { renderTemplate } from "../../domain/scaffold.ts";
7
+ import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
8
+
9
+ export type NewKind = "feature" | "change" | "adr";
10
+
11
+ export interface NewRequest {
12
+ kind: NewKind;
13
+ slug: string;
14
+ title: string;
15
+ /** For a change: the feature it belongs to. Without one, the change is standalone. */
16
+ feature?: string;
17
+ /** The roadmap item this feature or standalone change delivers; defaults to the slug. */
18
+ roadmap?: string;
19
+ }
20
+
21
+ export interface NewReport {
22
+ kind: "feature" | "change" | "standalone" | "adr";
23
+ ref: string;
24
+ path: string;
25
+ }
26
+
27
+ /** Creates a feature, change or ADR from its template, with a fresh ID. */
28
+ export async function createWorkItem(deps: ProjectDeps, request: NewRequest): Promise<NewReport> {
29
+ const view = await loadView(deps.files);
30
+ const date = deps.clock.today();
31
+ const values = { id: "", title: request.title, date, project_name: "" };
32
+ if (request.title.trim() === "") throw new CruzeError("missing-title", "a title is required");
33
+
34
+ if (request.kind === "adr") {
35
+ requireSlug(request.slug);
36
+ const path = `${PATHS.adrDir}/${date}-${request.slug}.md`;
37
+ if (view.snapshot.has(path)) throw new CruzeError("exists", `${path} already exists`);
38
+ const text = renderTemplate(await deps.bundle.formatTemplate("adr.md"), values).replace(/^# .*$/m, `# ${request.title}`);
39
+ await deps.files.writeText(path, text);
40
+ await appendJournal(deps, ".cruze", "new", { kind: "adr", ref: path });
41
+ return { kind: "adr", ref: `ADR-${date}-${request.slug}`, path };
42
+ }
43
+
44
+ if (request.kind === "change" && request.feature !== undefined) {
45
+ const { item: feature } = resolveArtifact(view, request.feature);
46
+ if (feature === undefined || feature.kind !== "feature" || feature.archived) throw new CruzeError("not-a-feature", `"${request.feature}" is not an active feature`);
47
+ const row = changeRow(view, feature, request.slug);
48
+ const folder = `${feature.folder}/changes/${row.change}`;
49
+ const template = renderTemplate(await deps.bundle.formatTemplate("change.md"), { ...values, id: row.change });
50
+ await deps.files.writeText(`${folder}/change.md`, fillTemplate(template, { delivers: row.delivers, builds: [...row.builds, ...row.removes] }));
51
+ await appendJournal(deps, feature.folder, "new", { kind: "change", ref: `${feature.ref}/${row.change}` });
52
+ return { kind: "change", ref: `${feature.ref}/${row.change}`, path: `${folder}/change.md` };
53
+ }
54
+
55
+ const kind = request.kind === "feature" ? "feature" : "standalone";
56
+ const id = allocateId(view, date, request.slug);
57
+ const folder = `${kind === "feature" ? PATHS.featuresDir : PATHS.changesDir}/${id}`;
58
+ const roadmap = request.roadmap ?? (roadmapHasItem(view.snapshot.get(PATHS.roadmap), request.slug) ? request.slug : undefined);
59
+ const template = renderTemplate(await deps.bundle.formatTemplate(kind === "feature" ? "feature.md" : "standalone-change.md"), { ...values, id });
60
+ const path = `${folder}/${kind === "feature" ? "feature.md" : "change.md"}`;
61
+ await deps.files.writeText(path, fillTemplate(template, roadmap === undefined ? {} : { roadmap }));
62
+ const roadmapText = view.snapshot.get(PATHS.roadmap);
63
+ if (roadmap !== undefined && roadmapText !== undefined) {
64
+ await deps.files.writeText(PATHS.roadmap, upsertRow(roadmapText, "Status", [roadmap, id, "designing"]));
65
+ }
66
+ await appendJournal(deps, folder, "new", { kind, ref: id });
67
+ return { kind, ref: id, path };
68
+ }
69
+
70
+ /** Whether a roadmap phase lists this slug as an item. */
71
+ function roadmapHasItem(roadmap: string | undefined, slug: string): boolean {
72
+ if (roadmap === undefined) return false;
73
+ const phases = roadmap.split(/^## /m).find((section) => section.startsWith("Phases")) ?? "";
74
+ return phases.split("\n").some((line) => line.startsWith(`| ${slug} |`));
75
+ }
@@ -0,0 +1,73 @@
1
+ import { CruzeError } from "../../domain/cruze_error.ts";
2
+ import { readProgress } from "../../domain/project/progress.ts";
3
+ import { buildProjectView } from "../../domain/project/project_view.ts";
4
+ import { nextSteps, type NextReport } from "../../domain/status/next_step.ts";
5
+ import { activeChange, buildGateReasons, projectStatus, touchedIds, type ProjectStatus } from "../../domain/status/work_status.ts";
6
+ import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
7
+
8
+ export interface StatusReport extends ProjectStatus {
9
+ branch: string | null;
10
+ }
11
+
12
+ export async function showStatus(deps: ProjectDeps): Promise<StatusReport> {
13
+ const branch = await deps.repository.currentBranch();
14
+ return { branch, ...projectStatus(await loadView(deps.files), branch) };
15
+ }
16
+
17
+ /** The step to run next on this branch, and other work that could proceed. */
18
+ export async function suggestNext(deps: ProjectDeps): Promise<NextReport & { branch: string | null }> {
19
+ const branch = await deps.repository.currentBranch();
20
+ return { branch, ...nextSteps(await loadView(deps.files), branch) };
21
+ }
22
+
23
+ export interface GateReport {
24
+ gate: "build";
25
+ passed: boolean;
26
+ reasons: string[];
27
+ overridden: boolean;
28
+ }
29
+
30
+ /**
31
+ * The build gate: the change bound to this branch, its feature and the architecture are
32
+ * all approved and current. An override passes the gate and is journaled with its reason.
33
+ */
34
+ export async function checkBuildGate(deps: ProjectDeps, override?: string): Promise<GateReport> {
35
+ const view = await loadView(deps.files);
36
+ const branch = await deps.repository.currentBranch();
37
+ const reasons = buildGateReasons(view, branch);
38
+ if (reasons.length === 0) return { gate: "build", passed: true, reasons, overridden: false };
39
+ if (override === undefined) return { gate: "build", passed: false, reasons, overridden: false };
40
+ if (override.trim() === "") throw new CruzeError("override-reason", "an override needs a reason");
41
+ const folder = activeChange(view, branch)?.folder ?? ".cruze";
42
+ await appendJournal(deps, folder, "override", { gate: "build", reason: override, blocked_by: reasons });
43
+ return { gate: "build", passed: true, reasons, overridden: true };
44
+ }
45
+
46
+ export interface Overlap {
47
+ branch: string;
48
+ item: string;
49
+ shared: string[];
50
+ }
51
+
52
+ /** In-flight work on other branches that touches the same elements as this branch's change. */
53
+ export async function findOverlaps(deps: ProjectDeps, ref?: string): Promise<{ change: string; overlaps: Overlap[] }> {
54
+ const view = await loadView(deps.files);
55
+ const branch = await deps.repository.currentBranch();
56
+ const change = ref === undefined ? activeChange(view, branch) : view.items.find((item) => item.ref === ref);
57
+ if (change === undefined) throw new CruzeError("no-active-change", "no change is bound to this branch; name one with --change");
58
+ const mine = touchedIds(view, change);
59
+ const overlaps: Overlap[] = [];
60
+ for (const other of await deps.repository.otherBranches()) {
61
+ const theirs = buildProjectView(new Map([...view.snapshot].filter(([p]) => p.startsWith("docs/")).concat([...(await deps.repository.filesOnBranch(other, ".cruze"))])));
62
+ // Work in flight on a branch is the change bound to it.
63
+ const boundThere = theirs.items.filter((i) => {
64
+ const bound = readProgress(i.doc).branch;
65
+ return !i.archived && i.ref !== change.ref && bound !== undefined && (other === bound || other.endsWith(`/${bound}`));
66
+ });
67
+ for (const item of boundThere) {
68
+ const shared = [...touchedIds(theirs, item)].filter((id) => mine.has(id));
69
+ if (shared.length > 0) overlaps.push({ branch: other, item: item.ref, shared: shared.sort() });
70
+ }
71
+ }
72
+ return { change: change.ref, overlaps };
73
+ }