@printpractical/cruze 0.0.1 → 0.0.3

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 (104) hide show
  1. package/README.md +41 -17
  2. package/dist/adapters/inbound/cli/cli_context.js +1 -0
  3. package/dist/adapters/inbound/cli/commands/code.js +2 -1
  4. package/dist/adapters/inbound/cli/commands/install.js +4 -0
  5. package/dist/adapters/inbound/cli/commands/realign.js +28 -0
  6. package/dist/adapters/inbound/cli/commands/review.js +1 -1
  7. package/dist/adapters/inbound/cli/commands.js +3 -1
  8. package/dist/adapters/inbound/cli/parse_command.js +2 -0
  9. package/dist/adapters/inbound/cli/run_cli.js +9 -0
  10. package/dist/adapters/outbound/node_project_files.js +2 -1
  11. package/dist/adapters/outbound/process_agent_runner.js +8 -3
  12. package/dist/app/ports/agent_runner.js +5 -6
  13. package/dist/app/project_context.js +4 -2
  14. package/dist/app/use_cases/check_code.js +8 -3
  15. package/dist/app/use_cases/init_project.js +31 -3
  16. package/dist/app/use_cases/install_skills.js +8 -1
  17. package/dist/app/use_cases/journal_events.js +4 -3
  18. package/dist/app/use_cases/land_change.js +27 -6
  19. package/dist/app/use_cases/realign_done.js +45 -0
  20. package/dist/app/use_cases/realign_status.js +106 -0
  21. package/dist/app/use_cases/record_version.js +56 -0
  22. package/dist/app/use_cases/run_review.js +26 -12
  23. package/dist/app/use_cases/validate_project.js +10 -4
  24. package/dist/domain/check/check_source.js +23 -2
  25. package/dist/domain/config.js +18 -8
  26. package/dist/domain/journal.js +29 -5
  27. package/dist/domain/project/layout.js +1 -0
  28. package/dist/domain/scaffold.js +15 -1
  29. package/dist/domain/status/next_step.js +19 -2
  30. package/dist/domain/validation/plan_rules.js +18 -1
  31. package/dist/domain/validation/project_rules.js +17 -1
  32. package/dist/domain/validation/validate_project.js +2 -1
  33. package/dist/domain/validation/work_item_rules.js +7 -0
  34. package/dist/domain/versions/ci_pin.js +40 -0
  35. package/dist/domain/versions/cruze_version.js +51 -0
  36. package/dist/domain/versions/realign_notes.js +72 -0
  37. package/dist/domain/versions/rule_versions.js +22 -0
  38. package/dist/domain/versions/scheduled_exceptions.js +41 -0
  39. package/package.json +1 -1
  40. package/skills/architect/feature.md +4 -2
  41. package/skills/behavioural-testing/SKILL.md +6 -0
  42. package/skills/build/SKILL.md +1 -1
  43. package/skills/envision/project.md +4 -2
  44. package/skills/formats/reference/config.md +7 -3
  45. package/skills/formats/reference/work-items.md +1 -1
  46. package/skills/hexagonal-design/SKILL.md +7 -1
  47. package/skills/hexagonal-design/languages/rust.md +7 -2
  48. package/skills/land/SKILL.md +1 -1
  49. package/skills/next/SKILL.md +1 -0
  50. package/skills/plan/tasks.md +7 -3
  51. package/skills/plan/test-plan.md +1 -1
  52. package/skills/realign/SKILL.md +35 -0
  53. package/skills/realign/notes/0.0.2.md +69 -0
  54. package/skills/realign/notes/0.0.3.md +11 -0
  55. package/skills/realign/notes.md +30 -0
  56. package/skills/research/SKILL.md +2 -1
  57. package/skills/rethink/SKILL.md +3 -1
  58. package/skills/rethink/levels.md +1 -1
  59. package/skills/roles/SKILL.md +5 -2
  60. package/skills/roles/auditor.md +35 -0
  61. package/skills/roles/code-reviewer.md +4 -3
  62. package/skills/roles/design-reviewer.md +9 -6
  63. package/skills/roles/researcher.md +1 -0
  64. package/src/adapters/inbound/cli/cli_context.ts +1 -0
  65. package/src/adapters/inbound/cli/commands/code.ts +2 -1
  66. package/src/adapters/inbound/cli/commands/install.ts +4 -0
  67. package/src/adapters/inbound/cli/commands/realign.ts +28 -0
  68. package/src/adapters/inbound/cli/commands/review.ts +1 -1
  69. package/src/adapters/inbound/cli/commands.ts +3 -1
  70. package/src/adapters/inbound/cli/parse_command.ts +2 -0
  71. package/src/adapters/inbound/cli/run_cli.ts +9 -0
  72. package/src/adapters/outbound/node_project_files.ts +2 -1
  73. package/src/adapters/outbound/process_agent_runner.ts +8 -3
  74. package/src/app/ports/agent_runner.ts +5 -6
  75. package/src/app/project_context.ts +4 -2
  76. package/src/app/use_cases/check_code.ts +8 -3
  77. package/src/app/use_cases/init_project.ts +31 -3
  78. package/src/app/use_cases/install_skills.ts +8 -1
  79. package/src/app/use_cases/journal_events.ts +4 -3
  80. package/src/app/use_cases/land_change.ts +27 -6
  81. package/src/app/use_cases/realign_done.ts +45 -0
  82. package/src/app/use_cases/realign_status.ts +106 -0
  83. package/src/app/use_cases/record_version.ts +56 -0
  84. package/src/app/use_cases/run_review.ts +26 -12
  85. package/src/app/use_cases/validate_project.ts +10 -4
  86. package/src/domain/check/check_source.ts +23 -2
  87. package/src/domain/config.ts +18 -8
  88. package/src/domain/journal.ts +29 -5
  89. package/src/domain/project/layout.ts +1 -0
  90. package/src/domain/scaffold.ts +15 -1
  91. package/src/domain/status/next_step.ts +19 -2
  92. package/src/domain/validation/plan_rules.ts +18 -1
  93. package/src/domain/validation/project_rules.ts +17 -1
  94. package/src/domain/validation/validate_project.ts +2 -1
  95. package/src/domain/validation/work_item_rules.ts +7 -0
  96. package/src/domain/versions/ci_pin.ts +40 -0
  97. package/src/domain/versions/cruze_version.ts +51 -0
  98. package/src/domain/versions/realign_notes.ts +72 -0
  99. package/src/domain/versions/rule_versions.ts +22 -0
  100. package/src/domain/versions/scheduled_exceptions.ts +41 -0
  101. package/templates/init/config.yaml +5 -6
  102. package/templates/init/review/claude.yaml +7 -0
  103. package/templates/init/review/other.yaml +7 -0
  104. package/templates/init/CLAUDE.md +0 -1
@@ -0,0 +1,45 @@
1
+ import { CruzeError } from "../../domain/cruze_error.js";
2
+ import { resolveArtifact } from "../../domain/project/artifact_ref.js";
3
+ import { PATHS } from "../../domain/project/layout.js";
4
+ import { withVersionLine } from "../../domain/versions/cruze_version.js";
5
+ import { withScheduledExceptions } from "../../domain/versions/scheduled_exceptions.js";
6
+ import { appendJournal, loadView, } from "../project_context.js";
7
+ import { realignStatus } from "./realign_status.js";
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+ /**
19
+ * Moves `standards:` up to the installed Cruze once every mechanical finding is fixed, waived,
20
+ * or scheduled in a change. A scheduled `check` finding becomes an exception naming that change,
21
+ * so CI stays green until it lands, and landing it removes the exception.
22
+ */
23
+ export async function realignDone(deps , options ) {
24
+ const status = await realignStatus(deps, { full: false });
25
+ if (status.standards === status.target) return { from: status.standards, to: status.target, excepted: [] };
26
+ const open = status.notes.flatMap((note) => note.findings.filter((f) => !f.waived).map((f) => ({ note, finding: f })));
27
+ let scheduled ;
28
+ if (open.length > 0) {
29
+ if (options.change === undefined) {
30
+ const list = open.map(({ note, finding }) => `${note.id}: ${finding.path}${finding.line === undefined ? "" : `:${finding.line}`}`);
31
+ throw new CruzeError("realign-open", `fix, waive or schedule these first, then pass --change <ref> for the change that fixes the rest:\n ${list.join("\n ")}`);
32
+ }
33
+ const item = resolveArtifact(await loadView(deps.files), options.change).item;
34
+ if (item === undefined) throw new CruzeError("not-found", `"${options.change}" is not a feature or change; create the prefactor change first`);
35
+ scheduled = item.ref;
36
+ }
37
+
38
+ const excepted = open.filter(({ note }) => note.detect?.startsWith("check:") === true).map(({ note, finding }) => ({ path: finding.path, note: note.id }));
39
+ const config = (await deps.files.readText(PATHS.config)) ?? "";
40
+ const withExceptions = scheduled === undefined ? config : withScheduledExceptions(config, status.target, scheduled, excepted);
41
+ await deps.files.writeText(PATHS.config, withVersionLine(withExceptions, "standards", status.target));
42
+ const paths = [...new Set(excepted.map((e) => e.path))];
43
+ await appendJournal(deps, ".cruze", "realign", { from: status.standards, to: status.target, ...(scheduled === undefined ? {} : { scheduled }), excepted: paths });
44
+ return { from: status.standards, to: status.target, ...(scheduled === undefined ? {} : { scheduled }), excepted: paths };
45
+ }
@@ -0,0 +1,106 @@
1
+ import { CruzeError } from "../../domain/cruze_error.js";
2
+
3
+ import { PATHS } from "../../domain/project/layout.js";
4
+
5
+ import { validateProject } from "../../domain/validation/validate_project.js";
6
+ import { notesBetween, parseRealignNotes, } from "../../domain/versions/realign_notes.js";
7
+ import { FIRST_RELEASE } from "../../domain/versions/rule_versions.js";
8
+
9
+ import { loadView, } from "../project_context.js";
10
+ import { checkCode } from "./check_code.js";
11
+
12
+ export const REALIGN_SKILL = "realign";
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+ /**
41
+ * What realigning the project to its installed Cruze involves: the notes of every release
42
+ * since `standards:`, or of all releases with `full`, and what their CLI rules find now.
43
+ */
44
+ export async function realignStatus(deps , options ) {
45
+ const view = await loadView(deps.files);
46
+ const config = requireInstalled(view, deps.bundle.version);
47
+ const standards = config.standards ?? FIRST_RELEASE;
48
+ const target = deps.bundle.version;
49
+ const notes = notesBetween(await bundledNotes(deps.bundle), options.full ? "0.0.0" : standards, target);
50
+ const waived = waivedFindings(view);
51
+ const check = (await checkCode(deps, { ci: false })).findings;
52
+ const problems = validateProject(view);
53
+ const report = notes.filter((n) => n.appliesTo === "existing").map((note) => {
54
+ const [command, rule] = (note.detect ?? ":").split(":");
55
+ const found = command === "check" ? check.filter((f) => f.rule === rule) : command === "validate" ? problems.filter((p) => p.rule === rule) : [];
56
+ const findings = found.map((f) => ({
57
+ path: f.path,
58
+ ...(f.line === undefined ? {} : { line: f.line }),
59
+ message: f.message,
60
+ // A check finding is waived by listing the file under check.exceptions, which also keeps CI green.
61
+ waived: command === "validate" && waived.has(findingKey(note.id, f.path)),
62
+ }));
63
+ return { ...note, findings };
64
+ });
65
+ return {
66
+ standards,
67
+ target,
68
+ full: options.full,
69
+ notes: report,
70
+ newWorkOnly: notes.filter((n) => n.appliesTo === "new").map((n) => n.id),
71
+ open: report.flatMap((n) => n.findings).filter((f) => !f.waived).length,
72
+ };
73
+ }
74
+
75
+ /** The key a realign disposition records for a mechanical finding, so a waiver can be matched later. */
76
+ export function findingKey(note , path ) {
77
+ return `${note}: ${path}`;
78
+ }
79
+
80
+ /** The project's config, once its skills come from this CLI; realign works from the installed notes. */
81
+ export function requireInstalled(view , version ) {
82
+ const config = view.config?.config;
83
+ if (config === null || config === undefined) throw new CruzeError("config", `fix ${PATHS.config} first; run cruze validate for details`);
84
+ if (config.cruze !== version) {
85
+ throw new CruzeError("install-first", `the project's skills are from Cruze ${config.cruze ?? "an unrecorded version"}, and this CLI is ${version}; run cruze install first`);
86
+ }
87
+ return config;
88
+ }
89
+
90
+ async function bundledNotes(bundle ) {
91
+ const skill = (await bundle.skills()).find((s) => s.folder === REALIGN_SKILL);
92
+ return (skill?.files ?? [])
93
+ .flatMap((file) => {
94
+ const version = /^notes\/(\d+\.\d+\.\d+)\.md$/.exec(file.path)?.[1];
95
+ return version === undefined ? [] : parseRealignNotes(version, file.text).notes;
96
+ });
97
+ }
98
+
99
+ /** Findings earlier realigns waived, by key. */
100
+ function waivedFindings(view ) {
101
+ return new Set(
102
+ view.journal
103
+ .filter((e) => e.event === "disposition" && e["review"] === "realign" && e["disposition"] === "waived")
104
+ .map((e) => String(e["finding"])),
105
+ );
106
+ }
@@ -0,0 +1,56 @@
1
+ import { CruzeError } from "../../domain/cruze_error.js";
2
+ import { PATHS } from "../../domain/project/layout.js";
3
+ import { repinCruze } from "../../domain/versions/ci_pin.js";
4
+ import { compareVersions, versionSkew, withRecordedVersion } from "../../domain/versions/cruze_version.js";
5
+
6
+
7
+ export const CI_WORKFLOW = ".github/workflows/ci.yml";
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+ /** The version the project's config records, read without requiring the rest of the config to be valid. */
18
+ export async function recordedVersion(files ) {
19
+ const text = await files.readText(PATHS.config);
20
+ return text === undefined ? undefined : versionIn(text);
21
+ }
22
+
23
+ /** What to tell the user when a Cruze project's skills don't match this CLI; nothing outside a Cruze project. */
24
+ export async function versionNotice(files , version ) {
25
+ const text = await files.readText(PATHS.config);
26
+ return text === undefined ? undefined : versionSkew(version, versionIn(text))?.message;
27
+ }
28
+
29
+ function versionIn(configText ) {
30
+ return /^cruze:\s*["']?([^"'\s#]+)/m.exec(configText)?.[1];
31
+ }
32
+
33
+ /** Refuses to install older skills over a project that already moved to a newer Cruze. */
34
+ export async function refuseDowngrade(files , version ) {
35
+ const recorded = await recordedVersion(files);
36
+ if (recorded !== undefined && compareVersions(recorded, version) > 0) {
37
+ throw new CruzeError(
38
+ "older-cli",
39
+ `this project uses Cruze ${recorded}, and this CLI is ${version}; upgrade the CLI instead. To go back to ${version} on purpose, set cruze: in ${PATHS.config} first`,
40
+ );
41
+ }
42
+ }
43
+
44
+ /** Records the installed version in the config, and moves the CI workflow's Cruze to it. */
45
+ export async function recordVersion(files , version ) {
46
+ const from = await recordedVersion(files);
47
+ const config = await files.readText(PATHS.config);
48
+ if (config !== undefined) {
49
+ const updated = withRecordedVersion(config, version);
50
+ if (updated !== config) await files.writeText(PATHS.config, updated);
51
+ }
52
+ const ci = await files.readText(CI_WORKFLOW);
53
+ const repin = ci === undefined ? { text: "", repinned: [], unrecognised: [] } : repinCruze(ci, version);
54
+ if (ci !== undefined && repin.text !== ci) await files.writeText(CI_WORKFLOW, repin.text);
55
+ return { ...(from === undefined ? {} : { from }), to: version, ci: { repinned: repin.repinned, unrecognised: repin.unrecognised } };
56
+ }
@@ -4,7 +4,7 @@ import { featureOf } from "../../domain/project/work_items.js";
4
4
 
5
5
  import { loadView, } from "../project_context.js";
6
6
 
7
- export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher"] ;
7
+ export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher", "auditor"] ;
8
8
 
9
9
 
10
10
 
@@ -18,16 +18,19 @@ export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researche
18
18
 
19
19
 
20
20
 
21
-
22
-
23
-
24
-
25
-
26
-
21
+ /**
22
+ * The role's report, or, when the configured agent isn't installed, the prompt for the calling
23
+ * agent to run in a helper of its own.
24
+ */
25
+
26
+
27
+
28
+
27
29
 
28
30
  /**
29
31
  * Runs a Cruze role in a fresh agent context: the role's prompt, what it reviews, and nothing
30
- * from the conversation that produced the work. Returns the role's report as written.
32
+ * from the conversation that produced the work. Returns the role's report as written, or the
33
+ * prompt to run in a helper when the configured agent isn't installed.
31
34
  */
32
35
  export async function runReview(deps , request ) {
33
36
  if (!(ROLES ).includes(request.role)) throw new CruzeError("unknown-role", `the roles are ${ROLES.join(", ")}`);
@@ -55,12 +58,23 @@ export async function runReview(deps , req
55
58
  context.map((line) => `- ${line}`).join("\n"),
56
59
  ].join("\n\n");
57
60
 
58
- const command = view.config?.config?.review.command ?? [];
61
+ const subject = { role, ...(request.item === undefined ? {} : { item: request.item }) };
62
+ const command = view.config?.config?.review.command;
63
+ if (command === undefined) return { ...subject, status: "run-in-helper", instruction: helperInstruction(undefined), prompt };
59
64
  const run = await deps.runner.run(command, prompt);
65
+ if (run.kind === "missing") return { ...subject, command, status: "run-in-helper", instruction: helperInstruction(command[0]), prompt };
60
66
  if (run.exitCode !== 0) throw new CruzeError("review-failed", `${command.join(" ")} exited with ${run.exitCode}: ${run.error.trim() || run.output.trim()}`);
61
- const report = { role, command, report: run.output.trim() };
62
- if (request.item !== undefined) report.item = request.item;
63
- return report;
67
+ return { ...subject, command, status: "reported", report: run.output.trim() };
68
+ }
69
+
70
+ /** Why cruze review can't start the fresh context itself, and how the calling agent does instead. */
71
+ function helperInstruction(program ) {
72
+ return [
73
+ program === undefined ? "No review agent is configured under review.command." : `${program} is not installed, so cruze review can't start a fresh context.`,
74
+ "Start a helper agent with no conversation history, give it this prompt and nothing else, and take its report as the role's report.",
75
+ "If you can't start a helper, tell the user, and have them run the prompt in a new session.",
76
+ "To have cruze review start an agent itself, set review.command in .cruze/config.yaml.",
77
+ ].join(" ");
64
78
  }
65
79
 
66
80
  async function bundledRole(deps , role ) {
@@ -1,5 +1,8 @@
1
1
  import { hasErrors, validateProject } from "../../domain/validation/validate_project.js";
2
-
2
+ import { warning, } from "../../domain/validation/problem.js";
3
+ import { PATHS } from "../../domain/project/layout.js";
4
+ import { versionSkew } from "../../domain/versions/cruze_version.js";
5
+
3
6
  import { loadView } from "../project_context.js";
4
7
 
5
8
 
@@ -10,9 +13,12 @@ import { loadView } from "../project_context.js";
10
13
 
11
14
 
12
15
 
13
- /** Checks every document in the project against the cruze-formats rules. */
14
- export async function validate(deps ) {
15
- const problems = validateProject(await loadView(deps.files));
16
+ /** Checks every document in the project against the cruze-formats rules, and that its skills match this CLI. */
17
+ export async function validate(deps ) {
18
+ const view = await loadView(deps.files);
19
+ const config = view.config?.config;
20
+ const skew = config === null || config === undefined ? undefined : versionSkew(deps.bundle.version, config.cruze);
21
+ const problems = [...validateProject(view), ...(skew === undefined ? [] : [warning(PATHS.config, undefined, "cruze-version", skew.message)])];
16
22
  return {
17
23
  valid: !hasErrors(problems),
18
24
  errors: problems.filter((p) => p.severity === "error").length,
@@ -6,10 +6,12 @@ import { countTypes } from "./type_count.js";
6
6
 
7
7
 
8
8
 
9
-
9
+
10
10
 
11
11
 
12
12
 
13
+
14
+
13
15
 
14
16
 
15
17
 
@@ -56,9 +58,28 @@ export function checkSource(input ) {
56
58
  if (!excepted && types !== null && types > config.check.maxTypes) {
57
59
  findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
58
60
  }
59
- if (modulePaths.length > 0 && !modulePaths.some((module) => (module.endsWith("/") ? path.startsWith(module) : path === module))) {
61
+ if (!excepted && isRustTestFileInSource(path)) {
62
+ findings.push({ path, rule: "test-placement", severity: "warning", message: "Rust tests that leave their inline module go in the crate's tests/ directory, not under src/" });
63
+ }
64
+ if (modulePaths.length > 0 && !insideModuleMap(path, modulePaths)) {
60
65
  findings.push({ path, rule: "outside-module-map", severity: "warning", message: "the file is outside every module in the architecture's module map" });
61
66
  }
62
67
  }
63
68
  return findings;
64
69
  }
70
+
71
+ /** Whether a module's Path covers the file: a directory path ending in `/` covers everything under it, any other names one file. */
72
+ export function insideModuleMap(path , modulePaths ) {
73
+ return modulePaths.some((module) => (module.endsWith("/") ? path.startsWith(module) : path === module));
74
+ }
75
+
76
+ /** A Rust file of tests split out of a module: `tests.rs`, `*_tests.rs`, or anything under a `tests/` folder inside `src/`. */
77
+ function isRustTestFileInSource(path ) {
78
+ if (!path.endsWith(".rs")) return false;
79
+ const segments = path.split("/");
80
+ const inSource = segments.lastIndexOf("src");
81
+ if (inSource === -1) return false;
82
+ const below = segments.slice(inSource + 1);
83
+ const file = below.at(-1) ?? "";
84
+ return file === "tests.rs" || file.endsWith("_tests.rs") || below.slice(0, -1).includes("tests");
85
+ }
@@ -3,6 +3,10 @@ import { parse as parseYaml } from "yaml";
3
3
  /** `.cruze/config.yaml`, as described in the cruze-formats skill. */
4
4
 
5
5
 
6
+
7
+
8
+
9
+
6
10
 
7
11
 
8
12
 
@@ -15,13 +19,13 @@ import { parse as parseYaml } from "yaml";
15
19
 
16
20
 
17
21
 
18
-
19
-
22
+
23
+
24
+
25
+
26
+
20
27
 
21
28
 
22
- /** Claude Code in print mode, allowed to read the project, its history and run `cruze`, but not to edit. */
23
- export const DEFAULT_REVIEW_COMMAND = ["claude", "-p", "--allowedTools", "Read Grep Glob Bash(git diff:*) Bash(git log:*) Bash(git show:*) Bash(cruze:*)"];
24
-
25
29
 
26
30
 
27
31
 
@@ -43,6 +47,10 @@ export function parseConfig(text ) {
43
47
  if (root === null) return { config: null, problems: ["the file must be a YAML mapping"] };
44
48
 
45
49
  if (root["version"] !== 1) problems.push("version must be 1");
50
+ const cruze = root["cruze"];
51
+ if (cruze !== undefined && (typeof cruze !== "string" || cruze.trim() === "")) problems.push("cruze must be a version string, such as \"0.0.2\"");
52
+ const standards = root["standards"];
53
+ if (standards !== undefined && (typeof standards !== "string" || standards.trim() === "")) problems.push("standards must be a version string, such as \"0.0.2\"");
46
54
  if (typeof root["project"] !== "string" || root["project"] === "") problems.push("project must be a non-empty string");
47
55
  if (root["tracker"] !== "markdown") problems.push("tracker must be markdown");
48
56
  const source = stringList(root["source"], "source", problems);
@@ -87,11 +95,13 @@ export function parseConfig(text ) {
87
95
  }
88
96
  }
89
97
  const review = asRecord(root["review"]) ?? {};
90
- const reviewCommand = review["command"] === undefined ? DEFAULT_REVIEW_COMMAND : stringList(review["command"], "review.command", problems);
91
- if (reviewCommand.length === 0) problems.push("review.command needs at least the program to run");
98
+ const reviewCommand = review["command"] === undefined ? undefined : stringList(review["command"], "review.command", problems);
99
+ if (reviewCommand?.length === 0) problems.push("review.command needs at least the program to run");
92
100
 
93
101
  const config = {
94
102
  version: 1,
103
+ ...(typeof cruze === "string" && cruze.trim() !== "" ? { cruze } : {}),
104
+ ...(typeof standards === "string" && standards.trim() !== "" ? { standards } : {}),
95
105
  project: typeof root["project"] === "string" ? root["project"] : "",
96
106
  tracker: "markdown",
97
107
  source,
@@ -99,7 +109,7 @@ export function parseConfig(text ) {
99
109
  check: { maxLines: maxLines ?? 250, maxTypes: maxTypes ?? 5, exceptions },
100
110
  layers,
101
111
  commands,
102
- review: { command: reviewCommand.length === 0 ? DEFAULT_REVIEW_COMMAND : reviewCommand },
112
+ review: reviewCommand === undefined || reviewCommand.length === 0 ? {} : { command: reviewCommand },
103
113
  };
104
114
  return { config, problems };
105
115
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  /** Events other tools and skills record, with the fields each requires. */
11
11
  export const RECORDED_EVENTS = {
12
- rethink: ["level", "kind", "summary", "wrong", "caught_by"],
12
+ rethink: ["level", "kind", "summary", "wrong", "found_by"],
13
13
  disposition: ["finding", "disposition", "reason", "review"],
14
14
  review: ["review", "round", "blockers", "concerns"],
15
15
  override: ["gate", "reason"],
@@ -17,14 +17,28 @@ export const RECORDED_EVENTS = {
17
17
  verification: ["result", "summary"],
18
18
  };
19
19
 
20
+ /** Fields an event may carry beyond its required ones, checked when present. A defect rethink must say which step missed it. */
21
+ const OPTIONAL_FIELDS = {
22
+ rethink: ["missed_by"],
23
+ review: ["nits"],
24
+ };
25
+
26
+ /** The workflow steps and checks that find or miss a problem, so a retro can count them. */
27
+ export const STEPS = ["envision", "architect", "research", "design-review", "walkthrough", "plan", "plan-review", "build", "code-review", "verify", "land", "realign", "validate", "check", "user"];
28
+
20
29
  export const FIELD_VALUES = {
21
30
  level: ["task", "change", "feature", "architecture", "vision"],
22
31
  kind: ["defect", "discovery"],
23
32
  disposition: ["fixed", "waived", "deferred", "rejected"],
24
- review: ["design", "plan", "code"],
33
+ review: ["design", "plan", "code", "realign"],
25
34
  result: ["accepted", "sent-back"],
35
+ found_by: STEPS,
36
+ missed_by: STEPS,
26
37
  };
27
38
 
39
+ /** Fields recorded as numbers rather than text. */
40
+ const COUNT_FIELDS = ["round", "blockers", "concerns", "nits"];
41
+
28
42
  export function journalPath(folder ) {
29
43
  return `${folder}/journal.jsonl`;
30
44
  }
@@ -53,10 +67,20 @@ export function serializeEntry(entry ) {
53
67
  export function recordedEventProblems(event , fields ) {
54
68
  const required = RECORDED_EVENTS[event];
55
69
  if (required === undefined) return [`unknown event "${event}"; recordable events: ${Object.keys(RECORDED_EVENTS).join(", ")}`];
56
- const problems = required.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}`);
57
- for (const [field, allowed] of Object.entries(FIELD_VALUES)) {
70
+ const needed = event === "rethink" && fields["kind"] === "defect" ? [...required, "missed_by"] : required;
71
+ const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${field === "missed_by" ? " for a defect" : ""}`);
72
+ const known = [...required, ...(OPTIONAL_FIELDS[event] ?? [])];
73
+ for (const field of known) {
58
74
  const value = fields[field];
59
- if (value !== undefined && required.includes(field) && !allowed.includes(value)) problems.push(`${field} must be one of ${allowed.join(", ")}`);
75
+ if (value === undefined) continue;
76
+ const allowed = FIELD_VALUES[field];
77
+ if (allowed !== undefined && !allowed.includes(value)) problems.push(`${field} must be one of ${allowed.join(", ")}`);
78
+ if (COUNT_FIELDS.includes(field) && !/^\d+$/.test(value)) problems.push(`${field} must be a whole number`);
60
79
  }
61
80
  return problems;
62
81
  }
82
+
83
+ /** The fields as the journal stores them: counts become numbers. */
84
+ export function recordedFields(fields ) {
85
+ return Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, COUNT_FIELDS.includes(key) ? Number(value) : value]));
86
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
 
6
6
  export const PATHS = {
7
+ readme: "README.md",
7
8
  vision: "docs/vision.md",
8
9
  glossary: "docs/glossary.md",
9
10
  architecture: "docs/architecture.md",
@@ -11,12 +11,26 @@ export const INIT_SCAFFOLD = [
11
11
  { path: "README.md", template: "init/README.md" },
12
12
  { path: "CHANGELOG.md", template: "init/CHANGELOG.md" },
13
13
  { path: "AGENTS.md", template: "init/AGENTS.md" },
14
- { path: "CLAUDE.md", template: "init/CLAUDE.md" },
15
14
  { path: ".cruze/config.yaml", template: "init/config.yaml" },
16
15
  { path: ".github/workflows/ci.yml", template: "init/ci.yml" },
17
16
  { path: ".gitattributes", template: "init/gitattributes" },
18
17
  ];
19
18
 
19
+ /**
20
+ * The agents `cruze init` sets a project up for: Claude Code, which gets `CLAUDE.md`, linked skills
21
+ * and a review command, or any other agent, which reads `AGENTS.md` and `.agents/skills/`.
22
+ */
23
+ export const INIT_AGENTS = ["claude", "other"] ;
24
+
25
+
26
+ /** Claude Code reads `CLAUDE.md`, so init links it to `AGENTS.md` rather than keeping a second copy. */
27
+ export const CLAUDE_INSTRUCTIONS = { path: "CLAUDE.md", target: "AGENTS.md" };
28
+
29
+ /** The template for the config's `review:` setting, which only Claude Code gets filled in. */
30
+ export function reviewSettingTemplate(agent ) {
31
+ return `init/review/${agent}.yaml`;
32
+ }
33
+
20
34
  /** Replaces every `{{key}}` with its value; an unknown key is a template bug. */
21
35
  export function renderTemplate(template , values ) {
22
36
  return template.replace(/\{\{([a-z_]+)\}\}/g, (placeholder, key ) => {
@@ -5,12 +5,14 @@ import { readProgress } from "../project/progress.js";
5
5
 
6
6
  import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.js";
7
7
  import { changesOf, featureOf, } from "../project/work_items.js";
8
+ import { compareVersions } from "../versions/cruze_version.js";
9
+ import { FIRST_RELEASE } from "../versions/rule_versions.js";
8
10
  import { isVerified } from "./verification.js";
9
11
  import { activeChange } from "./work_status.js";
10
12
 
11
13
  /** The Cruze step to run next, computed from the project's state and the current branch. */
12
14
 
13
-
15
+
14
16
 
15
17
 
16
18
 
@@ -31,12 +33,27 @@ export function nextSteps(view , branch )
31
33
  const active = activeChange(view, branch);
32
34
  const inFlight = view.items.filter((item) => !item.archived && item.kind !== "change").flatMap((item) => itemSteps(view, item, branch));
33
35
  const roadmap = roadmapStep(view);
34
- const candidates = [...(active === undefined ? [] : [changeStep(view, active, branch)]), ...inFlight, ...(roadmap === null ? [] : [roadmap])];
36
+ const realign = realignStep(view);
37
+ const candidates = [
38
+ ...(active === undefined ? [] : [changeStep(view, active, branch)]),
39
+ ...inFlight,
40
+ ...(roadmap === null ? [] : [roadmap]),
41
+ ...(realign === null ? [] : [realign]),
42
+ ];
35
43
  const unique = candidates.filter((c, i) => candidates.findIndex((o) => o.step === c.step && o.target === c.target) === i);
36
44
  const [next, ...also] = unique;
37
45
  return { next: next ?? { step: "roadmap", reason: "every item on the roadmap has landed; close the release and plan the next" }, also };
38
46
  }
39
47
 
48
+ /** Realign comes when nothing else is due: the installed skills expect more than the code meets. */
49
+ function realignStep(view ) {
50
+ const config = view.config?.config;
51
+ if (config?.cruze === undefined) return null;
52
+ const standards = config.standards ?? FIRST_RELEASE;
53
+ if (compareVersions(standards, config.cruze) >= 0) return null;
54
+ return { step: "realign", target: config.cruze, reason: `the code meets Cruze ${standards}'s expectations, and the installed skills are ${config.cruze}` };
55
+ }
56
+
40
57
  /** The project documents come first: nothing else can proceed without them. */
41
58
  function projectStep(view ) {
42
59
  const docs = [
@@ -1,8 +1,10 @@
1
+ import { insideModuleMap } from "../check/check_source.js";
1
2
  import { factsOf, isUnbuilt } from "../elements.js";
3
+ import { matchesAny } from "../glob.js";
2
4
  import { findIds, kindOf } from "../ids.js";
3
5
 
4
6
 
5
- import { isPlanned, readScope, readTasks, readTestPlan, } from "../project/plan_parts.js";
7
+ import { isPlanned, readScope, readTasks, readTestPlan, } from "../project/plan_parts.js";
6
8
 
7
9
  import { error, warning, } from "./problem.js";
8
10
 
@@ -37,6 +39,7 @@ export function changePlanProblems(
37
39
  problems.push(error(path, task.line, "unknown-id", `${task.number} proves ${id}, which is not defined`));
38
40
  }
39
41
  }
42
+ problems.push(...outsideModuleMap(view, path, tasks, delta));
40
43
  for (const id of [...scope.delivers, ...scope.builds]) {
41
44
  if (kindOf(id) === "MOD") {
42
45
  const modulePaths = modulePathsOf(view, delta.doc, delta.delta, id);
@@ -103,3 +106,17 @@ function factsOfScoped(view , doc , delta , id
103
106
  function modulePathsOf(view , doc , delta , id ) {
104
107
  return (factsOfScoped(view, doc, delta, id).get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")).filter((p) => p !== "");
105
108
  }
109
+
110
+ /** Task paths that `cruze check` would report as outside every module, caught while the plan can still fix the module map. */
111
+ function outsideModuleMap(view , path , tasks , delta ) {
112
+ const source = view.config?.config?.source ?? [];
113
+ const removed = new Set(delta.delta.entries.filter((e) => e.element.op === "REMOVED").map((e) => e.element.id));
114
+ const modules = new Set([...view.living.elements.keys(), ...delta.delta.entries.map((e) => e.element.id)].filter((id) => kindOf(id) === "MOD" && !removed.has(id)));
115
+ const modulePaths = [...modules].flatMap((id) => modulePathsOf(view, delta.doc, delta.delta, id));
116
+ if (modulePaths.length === 0) return [];
117
+ return tasks.flatMap((task) =>
118
+ task.paths
119
+ .filter((file) => matchesAny(file, source) && !insideModuleMap(file, modulePaths))
120
+ .map((file) => warning(path, task.line, "outside-module-map", `${task.number} writes ${file}, which no module's Path covers; add it to a module's Path or move the file`)),
121
+ );
122
+ }
@@ -7,7 +7,7 @@ import { readRoadmapItems } from "../project/roadmap.js";
7
7
 
8
8
  import { error, warning, } from "./problem.js";
9
9
 
10
- /** Rules that span the project: leftover template guides, the roadmap and the config. */
10
+ /** Rules that span the project: leftover template guides, the roadmap, the config and the README. */
11
11
 
12
12
  const GUIDE = /(?<!`)<(?!!--|\/|https?:|br\b)[A-Za-z][^<>\n]*>(?!`)/;
13
13
  const PLACEHOLDER = /\{\{[a-z_]+\}\}/;
@@ -97,3 +97,19 @@ export function configProblems(view ) {
97
97
  function modulePaths(doc , element ) {
98
98
  return (factsOf(doc, element).get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? ""));
99
99
  }
100
+
101
+ /**
102
+ * Once the vision is approved, the README says what the project is under its title.
103
+ * Images, badges and HTML lines don't count as that overview.
104
+ */
105
+ export function readmeProblems(view ) {
106
+ const text = view.snapshot.get(PATHS.readme);
107
+ const visionApproved = [...view.approvals.values()].some((file) => file.approvals.some((record) => record.artifact === PATHS.vision));
108
+ if (text === undefined || !visionApproved) return [];
109
+ const doc = parseMarkdown(text);
110
+ const title = doc.lines.findIndex((line) => /^# /.test(line));
111
+ const next = doc.lines.findIndex((line, i) => i > title && /^#{1,6} /.test(line));
112
+ const intro = doc.lines.slice(title + 1, next === -1 ? undefined : next);
113
+ const overview = intro.some((line, i) => !doc.inFence[title + 1 + i] && line.trim() !== "" && !/^\s*(!\[|\[!\[|<)/.test(line));
114
+ return overview ? [] : [warning(PATHS.readme, Math.max(title, 0), "readme-overview", "add a paragraph under the title saying what the project is, who it is for and why, from docs/vision.md")];
115
+ }
@@ -1,7 +1,7 @@
1
1
 
2
2
  import { livingProblems } from "./living_rules.js";
3
3
  import { error, sortProblems, } from "./problem.js";
4
- import { configProblems, roadmapProblems, templateLeftovers } from "./project_rules.js";
4
+ import { configProblems, readmeProblems, roadmapProblems, templateLeftovers } from "./project_rules.js";
5
5
  import { workItemProblems } from "./work_item_rules.js";
6
6
 
7
7
  /** Every rule in the cruze-formats skill, applied to the whole project. */
@@ -16,6 +16,7 @@ export function validateProject(view ) {
16
16
  ...roadmapProblems(view),
17
17
  ...workItemProblems(view),
18
18
  ...templateLeftovers(view),
19
+ ...readmeProblems(view),
19
20
  ...approvalProblems,
20
21
  ...journalProblems,
21
22
  ]);