@printpractical/cruze 0.0.14 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ It works with any agent that supports the [Agent Skills](https://agentskills.io)
10
10
 
11
11
  ## Status
12
12
 
13
- Version 0.0.14 is a rewrite. The earlier previews ran thirteen steps with design reviews, plans, verifiers, journals and merged living specs. Measured across nine projects, they took about twice the time of a plain design doc and plan for code of the same quality, and asked the user so many questions that most answers were a bare "agree". This version keeps what paid off and drops the rest. Nothing is compatible with 0.0.13 projects. Claude Code is the agent tested so far.
13
+ Version 0.0.14 was a rewrite. The earlier previews ran thirteen steps with design reviews, plans, verifiers, journals and merged living specs. Measured across nine projects, they took about twice the time of a plain design doc and plan for code of the same quality, and asked the user so many questions that most answers were a bare "agree". It kept what paid off and dropped the rest. Nothing is compatible with 0.0.13 projects. Claude Code is the agent tested so far.
14
14
 
15
15
  ## The flow
16
16
 
@@ -48,7 +48,7 @@ The skills run `cruze` commands, so the agent needs `cruze` on its path. Install
48
48
  From a GitHub Release, without npm:
49
49
 
50
50
  ```sh
51
- npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.14/printpractical-cruze-0.0.14.tgz
51
+ npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.15/printpractical-cruze-0.0.15.tgz
52
52
  ```
53
53
 
54
54
  From npm, once a version is published there:
@@ -60,7 +60,7 @@ npm install -g @printpractical/cruze
60
60
  From the git repository, at a tag or `#main`. npm 11 needs `--install-links` for a global git install, and the first `cruze` command builds the CLI:
61
61
 
62
62
  ```sh
63
- npm install -g --install-links github:PrintPractical/cruze#v0.0.14
63
+ npm install -g --install-links github:PrintPractical/cruze#v0.0.15
64
64
  ```
65
65
 
66
66
  ### 2. Set up a repository
@@ -74,7 +74,7 @@ cruze init
74
74
  When you didn't install from npm, tell `init` where Cruze came from, so the CI it writes runs the same source:
75
75
 
76
76
  ```sh
77
- cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.14/printpractical-cruze-0.0.14.tgz
77
+ cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.15/printpractical-cruze-0.0.15.tgz
78
78
  ```
79
79
 
80
80
  `init` asks for the project name and your coding agent: `claude` for Claude Code, or `other` for any agent that reads `AGENTS.md` and `.agents/skills/`. It creates `README.md`, `CHANGELOG.md`, `AGENTS.md` (and `CLAUDE.md` for Claude Code), `.cruze/config.yaml`, a CI workflow that runs `cruze validate`, `check` and `trace`, and the skills in `.agents/skills/`, linked into `.claude/skills/` for Claude Code. It never overwrites an existing file.
@@ -98,7 +98,7 @@ When the CLI you run differs from the version the project records, every command
98
98
  | `cruze init` | Sets up the repository and installs the skills |
99
99
  | `cruze install` | Installs or updates the skills |
100
100
  | `cruze new vision\|architecture\|feature <slug>` | Starts a document from its template |
101
- | `cruze validate` | Checks the documents: required sections, scenario IDs, the release's features and needs, size, leftover template text |
101
+ | `cruze validate` | Checks the documents: required sections, scenario IDs, the release's features and needs, leftover template text |
102
102
  | `cruze approve <vision\|architecture\|slug>` | Records that you approved a document as it reads now |
103
103
  | `cruze status` | Each document's approval and each feature's progress, in release order, and the next step |
104
104
  | `cruze next` | The step to run next, and why |
@@ -15,5 +15,6 @@ export function describeSkillInstall(report ) {
15
15
  if (from !== to) lines.push(`Recorded Cruze ${to} in .cruze/config.yaml${from === undefined ? "" : `, up from ${from}`}`);
16
16
  if (ci.repinned.length > 0) lines.push(`CI runs ${ci.repinned.join(", ")}`);
17
17
  for (const spec of ci.unrecognised) lines.push(`CI runs ${spec}, which Cruze can't move to ${to}; update .github/workflows/ci.yml yourself`);
18
+ for (const note of report.notes) lines.push(`Note: ${note}`);
18
19
  return lines;
19
20
  }
@@ -1,9 +1,12 @@
1
1
  import { posix } from "node:path";
2
2
  import { CruzeError } from "../../domain/cruze_error.js";
3
3
  import { AGENT_SKILL_HOMES, SHARED_SKILLS_DIR, agentSkillHome, knownAgents } from "../../domain/agent_skill_homes.js";
4
+ import { reviewSettingTemplate } from "../../domain/scaffold.js";
4
5
  import { SKILL_FILE, installedSkillName, isInstalledSkillName, skillProblems } from "../../domain/skill.js";
6
+ import { reviewCommandProblems } from "../../domain/validation/review_rules.js";
5
7
 
6
8
 
9
+ import { loadDocs } from "../project_context.js";
7
10
  import { recordVersion, refuseDowngrade, } from "./record_version.js";
8
11
 
9
12
 
@@ -18,6 +21,8 @@ import { recordVersion, refuseDowngrade, } from "./record_ver
18
21
 
19
22
 
20
23
 
24
+
25
+
21
26
 
22
27
 
23
28
  /**
@@ -55,7 +60,8 @@ export async function installSkills(
55
60
  }
56
61
 
57
62
  const recorded = await recordVersion(files, bundle.version);
58
- return { version: bundle.version, installed: names, removed, linked, recorded };
63
+ const notes = reviewCommandProblems(await loadDocs(files), await bundle.template(reviewSettingTemplate("claude"))).map((problem) => problem.message);
64
+ return { version: bundle.version, installed: names, removed, linked, recorded, notes };
59
65
  }
60
66
 
61
67
  function assertValid(skills ) {
@@ -1,6 +1,8 @@
1
1
  import { hasErrors, validateProject } from "../../domain/validation/validate_project.js";
2
2
  import { warning, } from "../../domain/validation/problem.js";
3
3
  import { PATHS } from "../../domain/project/layout.js";
4
+ import { reviewSettingTemplate } from "../../domain/scaffold.js";
5
+ import { reviewCommandProblems } from "../../domain/validation/review_rules.js";
4
6
  import { versionSkew } from "../../domain/versions/cruze_version.js";
5
7
 
6
8
 
@@ -13,12 +15,13 @@ import { loadDocs } from "../project_context.js";
13
15
 
14
16
 
15
17
 
16
- /** Checks every document in the project against the cruze-formats rules, and that its skills match this CLI. */
18
+ /** Checks every document in the project against the cruze-formats rules, that its skills match this CLI, and that its reviewer can run the tests. */
17
19
  export async function validate(deps ) {
18
20
  const docs = await loadDocs(deps.files);
19
21
  const config = docs.config?.config;
20
22
  const skew = config === null || config === undefined ? undefined : versionSkew(deps.bundle.version, config.cruze);
21
- const problems = [...validateProject(docs), ...(skew === undefined ? [] : [warning(PATHS.config, undefined, "cruze-version", skew.message)])];
23
+ const review = reviewCommandProblems(docs, await deps.bundle.template(reviewSettingTemplate("claude")));
24
+ const problems = [...validateProject(docs), ...(skew === undefined ? [] : [warning(PATHS.config, undefined, "cruze-version", skew.message)]), ...review];
22
25
  return {
23
26
  valid: !hasErrors(problems),
24
27
  errors: problems.filter((p) => p.severity === "error").length,
@@ -37,7 +37,7 @@ export function checkSource(input ) {
37
37
  findings.push({ path, rule: "no-layer", severity: "warning", message: "the file is in no layer, so its imports are unchecked" });
38
38
  }
39
39
  if (layer !== undefined) {
40
- for (const found of importsOf(path, text)) {
40
+ for (const found of importsOf(path, text, all)) {
41
41
  const target = resolveImport(found, all);
42
42
  const targetLayer = target === undefined ? undefined : layerOf(target);
43
43
  if (target === undefined || targetLayer === undefined || targetLayer.name === layer.name) continue;
@@ -12,11 +12,11 @@ import { rustImportCandidates } from "./rust_imports.js";
12
12
  const JS = /\.(?:[cm]?[jt]sx?)$/;
13
13
  const JS_EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", "/index.ts", "/index.js"];
14
14
 
15
- /** Imports that point inside the project. Imports of libraries and the standard library are left out. */
16
- export function importsOf(path , text ) {
15
+ /** Imports that point inside the project, among `files`. Imports of libraries and the standard library are left out. */
16
+ export function importsOf(path , text , files ) {
17
17
  if (JS.test(path)) return jsImports(path, text);
18
18
  if (path.endsWith(".py")) return pythonImports(path, text);
19
- if (path.endsWith(".rs")) return rustImportCandidates(path, text).map((i) => ({ ...i, bySuffix: false }));
19
+ if (path.endsWith(".rs")) return rustImportCandidates(path, text, files).map((i) => ({ ...i, bySuffix: false }));
20
20
  if (/\.(?:c|h|cc|cpp|cxx|hh|hpp|hxx)$/.test(path)) return matches(text, /^\s*#\s*include\s+"([^"]+)"/gm, (target) => [target], true);
21
21
  if (path.endsWith(".go")) return goImports(text);
22
22
  if (/\.(?:java|kt)$/.test(path)) return matches(text, /^import\s+(?:static\s+)?([\w.]+)(?:\.\*)?;?\s*$/gm, (t) => [`${t.replace(/\./g, "/")}.java`, `${t.replace(/\./g, "/")}.kt`], true);
@@ -1,10 +1,14 @@
1
- /** Resolves Rust `use` paths inside a crate to candidate source files. */
1
+ /** Resolves Rust `use` paths, inside a crate and between a workspace's crates, to candidate source files. */
2
2
 
3
- /** Candidate files for each `use crate::…`, `use super::…` and `use self::…` path in a Rust file. */
4
- export function rustImportCandidates(path , text ) {
3
+ /**
4
+ * Candidate files for each `use crate::…`, `use super::…` and `use self::…` path in a Rust file,
5
+ * and for each `use other_crate::…` that names a library crate among `files`.
6
+ */
7
+ export function rustImportCandidates(path , text , files ) {
5
8
  const root = crateRoot(path);
6
9
  if (root === null) return [];
7
10
  const own = moduleSegments(path, root);
11
+ const crates = libraryCrates(files);
8
12
  const results = [];
9
13
  const pattern = /^\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/gms;
10
14
  for (const match of text.matchAll(pattern)) {
@@ -12,11 +16,29 @@ export function rustImportCandidates(path , text )
12
16
  for (const segments of expandUse((match[1] ?? "").replace(/\s+/g, ""))) {
13
17
  const absolute = absoluteSegments(segments, own);
14
18
  if (absolute !== null) results.push({ line, candidates: candidatesFor(root, absolute) });
19
+ else {
20
+ const [head, ...rest] = segments;
21
+ const other = head === undefined ? undefined : crates.get(head);
22
+ if (other !== undefined && other !== root) results.push({ line, candidates: candidatesFor(other, rest) });
23
+ }
15
24
  }
16
25
  }
17
26
  return results;
18
27
  }
19
28
 
29
+ /**
30
+ * Each library crate in the project by the name code imports it as: the directory
31
+ * holding `src/lib.rs`, with hyphens as underscores, as Cargo names it by default.
32
+ */
33
+ function libraryCrates(files ) {
34
+ const crates = new Map ();
35
+ for (const file of files) {
36
+ const match = /^(?:.*\/)?([^/]+)\/src\/lib\.rs$/.exec(file);
37
+ if (match !== null) crates.set((match[1] ?? "").replace(/-/g, "_"), file.slice(0, -"lib.rs".length));
38
+ }
39
+ return crates;
40
+ }
41
+
20
42
  /** The directory holding the crate root: everything up to and including `src/`. */
21
43
  function crateRoot(path ) {
22
44
  const index = path.lastIndexOf("src/");
@@ -11,7 +11,7 @@ export const MAX_FILE_WORDS = 2100;
11
11
  * as every fix added rules and nothing was removed; 0.0.14 cut them to this. Lower it when text is
12
12
  * cut. Raising it is the maintainer's decision, with the reason in the commit that raises it.
13
13
  */
14
- export const BUNDLE_WORD_BUDGET = 17605;
14
+ export const BUNDLE_WORD_BUDGET = 17725;
15
15
 
16
16
  /** Data the CLI reads or renders, rather than instructions an agent reads. */
17
17
  const NOT_INSTRUCTIONS = /(^|\/)(templates|notes)\//;
@@ -3,34 +3,29 @@ import { findSection, } from "../markdown.js";
3
3
  import { FEATURE_SECTIONS, FEATURE_STATUSES, statusLine, strayScenarioIds, } from "../project/features.js";
4
4
  import { PATHS } from "../project/layout.js";
5
5
 
6
- import { error, warning, } from "./problem.js";
6
+ import { error, } from "./problem.js";
7
7
 
8
- /** Each document's required sections, its size, its scenarios, and leftover template guides. */
8
+ /** Each document's required sections, its scenarios, and leftover template guides. */
9
9
 
10
10
  const VISION_SECTIONS = ["Problem", "Users", "Release"];
11
11
  const ARCHITECTURE_SECTIONS = ["Overview", "Ports", "Adapters", "Runtime"];
12
12
 
13
- /** Words past which a document stops being something a person reads in one sitting. */
14
- export const WORD_LIMITS = { vision: 1500, architecture: 3000, feature: 2500 } ;
15
-
16
13
  const GUIDE = /(?<!`)<(?!!--|\/|https?:|br\b)[A-Za-z][^<>\n]*>(?!`)/;
17
14
  const PLACEHOLDER = /\{\{[a-z_]+\}\}/;
18
15
 
19
16
  export function documentProblems(docs ) {
20
17
  const problems = [];
21
- if (docs.vision !== undefined) problems.push(...shapeOf(PATHS.vision, docs.vision, VISION_SECTIONS, WORD_LIMITS.vision));
22
- if (docs.architecture !== undefined) problems.push(...shapeOf(PATHS.architecture, docs.architecture, ARCHITECTURE_SECTIONS, WORD_LIMITS.architecture));
18
+ if (docs.vision !== undefined) problems.push(...shapeOf(PATHS.vision, docs.vision, VISION_SECTIONS));
19
+ if (docs.architecture !== undefined) problems.push(...shapeOf(PATHS.architecture, docs.architecture, ARCHITECTURE_SECTIONS));
23
20
  for (const feature of docs.features) {
24
- problems.push(...shapeOf(feature.path, feature.doc, [...FEATURE_SECTIONS], WORD_LIMITS.feature), ...featureProblems(feature));
21
+ problems.push(...shapeOf(feature.path, feature.doc, [...FEATURE_SECTIONS]), ...featureProblems(feature));
25
22
  }
26
23
  problems.push(...duplicateScenarios(docs.features));
27
24
  return problems;
28
25
  }
29
26
 
30
- function shapeOf(path , doc , sections , limit ) {
27
+ function shapeOf(path , doc , sections ) {
31
28
  const problems = sections.filter((title) => findSection(doc, title) === null).map((title) => error(path, undefined, "missing-section", `needs a ## ${title} section`));
32
- const words = doc.lines.filter((_, i) => !doc.inFence[i]).join(" ").split(/\s+/).filter((w) => w !== "").length;
33
- if (words > limit) problems.push(warning(path, undefined, "too-long", `${words} words, over the ${limit} a person reads in one sitting; cut what the agent decided and the user needn't read, or split it`));
34
29
  doc.lines.forEach((line, i) => {
35
30
  if (doc.inFence[i]) return;
36
31
  const withoutCode = line.replace(/`[^`]*`/g, "");
@@ -0,0 +1,21 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ import { PATHS } from "../project/layout.js";
3
+
4
+ import { warning, } from "./problem.js";
5
+
6
+ /**
7
+ * Warns when a feature is built but `review.command` is still the one `cruze init`
8
+ * wrote: the foundation never added the project's test commands, so the code
9
+ * reviewer can only read the code. `initSetting` is the review template init renders.
10
+ */
11
+ export function reviewCommandProblems(docs , initSetting ) {
12
+ const command = docs.config?.config?.review.command;
13
+ if (command === undefined || !docs.features.some((feature) => feature.status === "built")) return [];
14
+ if (JSON.stringify(command) !== JSON.stringify(initCommand(initSetting))) return [];
15
+ return [warning(PATHS.config, undefined, "review-tests", "review.command is still the one cruze init wrote, so the code reviewer can't run the project's tests; add its test and lint commands as .agents/skills/cruze-formats/config.md shows")];
16
+ }
17
+
18
+ function initCommand(setting ) {
19
+ const parsed = parseYaml(setting);
20
+ return (parsed )?.review?.command;
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@printpractical/cruze",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "Spec-driven development for coding agents: one living model of the system, small deltas, convergent review.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Chris Buchter",
@@ -1,6 +1,6 @@
1
1
  # Designing the architecture map
2
2
 
3
- The architecture map is what every feature's shape and build read first. It says where things live and how the system runs, for the whole release, and leaves room for the future features. Keep it to what changes how code is written: under 3,000 words.
3
+ The architecture map is what every feature's shape and build read first. It says where things live and how the system runs, for the whole release, and leaves room for the future features. Keep it to what changes how code is written.
4
4
 
5
5
  Read `.agents/skills/cruze-hexagonal-design/SKILL.md` and its language file, and `.agents/skills/cruze-dependency-approval/SKILL.md`. When a choice needs evidence, such as which library fits or whether a platform supports something, follow `.agents/skills/cruze-research/SKILL.md`.
6
6
 
@@ -44,4 +44,4 @@ A later feature that changes an earlier one's behaviour edits that feature's fil
44
44
 
45
45
  ## Size
46
46
 
47
- A document is for a person to read in one sitting. `cruze validate` warns past 1,500 words for the vision, 3,000 for the architecture and 2,500 for a feature. Cut what the agent decided that the user needn't read, or split the feature.
47
+ A document is for a person to read. Leave out what the agent decided that the user needn't read, and split a feature that has grown past one shape-and-build cycle. No length is enforced, so never trim a document to a word count.
@@ -4,7 +4,7 @@
4
4
 
5
5
  ```yaml
6
6
  version: 2
7
- cruze: "0.0.14"
7
+ cruze: "0.0.15"
8
8
  project: "Console Access"
9
9
 
10
10
  source: ["src/**/*.rs"]
@@ -44,4 +44,4 @@ review:
44
44
  | `layers` | Each layer has a `name`, the `paths` it covers, and `may_import`, the layers it may depend on. |
45
45
  | `review.command` | The program and arguments `cruze review` runs to start an agent in a fresh context, with the prompt on standard input. Without it, `cruze review` hands the prompt back for the calling agent to run in a helper of its own. |
46
46
 
47
- A source file belongs to the first layer whose paths match it, and `cruze check` fails an import into a layer its own layer may not import. A layer may import itself, and code outside the project, such as the standard library and dependencies, is always allowed. Keep every source file in a layer: `cruze check` warns on one that isn't.
47
+ A source file belongs to the first layer whose paths match it, and `cruze check` fails an import into a layer its own layer may not import. In a Cargo workspace it follows `use other_crate::…` into that crate, named by its directory. A layer may import itself, and code outside the project, such as the standard library and dependencies, is always allowed. Keep every source file in a layer: `cruze check` warns on one that isn't.
@@ -12,7 +12,7 @@ A reviewer who never saw the build reads the code against the approved feature.
12
12
  1. **Run the code reviewer** in a fresh context, as `.agents/skills/cruze-roles/SKILL.md` says, on this feature, with the diff from where the feature's branch left `main`: `cruze review code-reviewer --feature <slug> --base <merge-base>`, where the merge base is `git merge-base main HEAD`. Done when you have its report.
13
13
  2. **Check each finding** against the code before acting on it. Drop one whose evidence doesn't hold, and say why in one line.
14
14
  3. **Sort them.** A finding is the user's when fixing it would change a scenario, the public API they approved, or a significant dependency. Every other finding is yours, blockers included.
15
- 4. **Fix yours.** Fix every blocker, and the concerns worth fixing, each with a test when it is a behaviour. Run the full suite, `cruze check --ci` and `cruze trace <slug>` again. Check your own fixes; don't run the reviewer a second time.
15
+ 4. **Fix yours.** Fix every blocker, and the concerns worth fixing, each with a test when it is a behaviour. Correct the documents where they no longer match the code, and show the user each change to an approved one with your report, for them to approve. Run the full suite, `cruze check --ci` and `cruze trace <slug>` again. Check your own fixes; don't run the reviewer a second time.
16
16
  5. **Bring the user theirs,** illustrated as `.agents/skills/cruze-asking/SKILL.md` says: the problem in plain words, who meets it and how, and the change to the feature you recommend, as code or output, before and after. Their answer goes through `shape` as a change to the approved feature, then back to build.
17
17
  6. **Report and open the pull request.** Tell the user, in a few lines:
18
18
  - what the feature now does;
@@ -10,7 +10,7 @@ You review the code of one feature before it lands, in a fresh context. You ask
10
10
  - The output of `cruze check` and `cruze trace <slug>`.
11
11
  - The standards: `.agents/skills/cruze-hexagonal-design/SKILL.md`, its language file for the project's language when one exists, `.agents/skills/cruze-behavioural-testing/SKILL.md` and `.agents/skills/cruze-dependency-approval/SKILL.md`.
12
12
 
13
- You may run the project's tests.
13
+ Run the project's tests and lints, one plain command at a time, with no pipes, `&&`, `;` or redirects: an agent's allowed commands match only a plain command. When a command is refused, say so in your report.
14
14
 
15
15
  ## Rubric
16
16
 
@@ -24,6 +24,7 @@ Review against these items and nothing else. Each finding names one.
24
24
  6. **Tests.** No test was weakened, skipped or deleted. Tests drive behaviour through public interfaces rather than restating what a fake was told.
25
25
  7. **Idiom.** The code is idiomatic for the language and follows its language file. Each `expect`, `unwrap` or panic has a reason its case can't happen.
26
26
  8. **Libraries.** Nothing hand-writes what a mature library does, and every significant dependency is in the architecture or approved.
27
+ 9. **Documents.** The feature's public API and stack sketch, and `docs/architecture.md` where the diff touches it, match the code: a port, operation, type, flag or claim the documents state that the code doesn't bear out, or the reverse.
27
28
 
28
29
  ## Findings
29
30
 
@@ -15,5 +15,6 @@ export function describeSkillInstall(report: InstallSkillsReport): string[] {
15
15
  if (from !== to) lines.push(`Recorded Cruze ${to} in .cruze/config.yaml${from === undefined ? "" : `, up from ${from}`}`);
16
16
  if (ci.repinned.length > 0) lines.push(`CI runs ${ci.repinned.join(", ")}`);
17
17
  for (const spec of ci.unrecognised) lines.push(`CI runs ${spec}, which Cruze can't move to ${to}; update .github/workflows/ci.yml yourself`);
18
+ for (const note of report.notes) lines.push(`Note: ${note}`);
18
19
  return lines;
19
20
  }
@@ -1,9 +1,12 @@
1
1
  import { posix } from "node:path";
2
2
  import { CruzeError } from "../../domain/cruze_error.ts";
3
3
  import { AGENT_SKILL_HOMES, SHARED_SKILLS_DIR, agentSkillHome, knownAgents } from "../../domain/agent_skill_homes.ts";
4
+ import { reviewSettingTemplate } from "../../domain/scaffold.ts";
4
5
  import { SKILL_FILE, installedSkillName, isInstalledSkillName, skillProblems } from "../../domain/skill.ts";
6
+ import { reviewCommandProblems } from "../../domain/validation/review_rules.ts";
5
7
  import type { Bundle, BundledSkill } from "../ports/bundle.ts";
6
8
  import type { ProjectFiles } from "../ports/project_files.ts";
9
+ import { loadDocs } from "../project_context.ts";
7
10
  import { recordVersion, refuseDowngrade, type VersionRecord } from "./record_version.ts";
8
11
 
9
12
  export interface InstallSkillsRequest {
@@ -18,6 +21,8 @@ export interface InstallSkillsReport {
18
21
  linked: Array<{ agent: string; dir: string }>;
19
22
  /** The version recorded in .cruze/config.yaml, and the CI workflow's Cruze pin. */
20
23
  recorded: VersionRecord;
24
+ /** What the project still has to change by hand, such as letting the reviewer run its tests. */
25
+ notes: string[];
21
26
  }
22
27
 
23
28
  /**
@@ -55,7 +60,8 @@ export async function installSkills(
55
60
  }
56
61
 
57
62
  const recorded = await recordVersion(files, bundle.version);
58
- return { version: bundle.version, installed: names, removed, linked, recorded };
63
+ const notes = reviewCommandProblems(await loadDocs(files), await bundle.template(reviewSettingTemplate("claude"))).map((problem) => problem.message);
64
+ return { version: bundle.version, installed: names, removed, linked, recorded, notes };
59
65
  }
60
66
 
61
67
  function assertValid(skills: BundledSkill[]): void {
@@ -1,6 +1,8 @@
1
1
  import { hasErrors, validateProject } from "../../domain/validation/validate_project.ts";
2
2
  import { warning, type Problem } from "../../domain/validation/problem.ts";
3
3
  import { PATHS } from "../../domain/project/layout.ts";
4
+ import { reviewSettingTemplate } from "../../domain/scaffold.ts";
5
+ import { reviewCommandProblems } from "../../domain/validation/review_rules.ts";
4
6
  import { versionSkew } from "../../domain/versions/cruze_version.ts";
5
7
  import type { Bundle } from "../ports/bundle.ts";
6
8
  import type { ProjectFiles } from "../ports/project_files.ts";
@@ -13,12 +15,13 @@ export interface ValidateReport {
13
15
  problems: Problem[];
14
16
  }
15
17
 
16
- /** Checks every document in the project against the cruze-formats rules, and that its skills match this CLI. */
18
+ /** Checks every document in the project against the cruze-formats rules, that its skills match this CLI, and that its reviewer can run the tests. */
17
19
  export async function validate(deps: { files: ProjectFiles; bundle: Bundle }): Promise<ValidateReport> {
18
20
  const docs = await loadDocs(deps.files);
19
21
  const config = docs.config?.config;
20
22
  const skew = config === null || config === undefined ? undefined : versionSkew(deps.bundle.version, config.cruze);
21
- const problems = [...validateProject(docs), ...(skew === undefined ? [] : [warning(PATHS.config, undefined, "cruze-version", skew.message)])];
23
+ const review = reviewCommandProblems(docs, await deps.bundle.template(reviewSettingTemplate("claude")));
24
+ const problems = [...validateProject(docs), ...(skew === undefined ? [] : [warning(PATHS.config, undefined, "cruze-version", skew.message)]), ...review];
22
25
  return {
23
26
  valid: !hasErrors(problems),
24
27
  errors: problems.filter((p) => p.severity === "error").length,
@@ -37,7 +37,7 @@ export function checkSource(input: CheckInput): CheckFinding[] {
37
37
  findings.push({ path, rule: "no-layer", severity: "warning", message: "the file is in no layer, so its imports are unchecked" });
38
38
  }
39
39
  if (layer !== undefined) {
40
- for (const found of importsOf(path, text)) {
40
+ for (const found of importsOf(path, text, all)) {
41
41
  const target = resolveImport(found, all);
42
42
  const targetLayer = target === undefined ? undefined : layerOf(target);
43
43
  if (target === undefined || targetLayer === undefined || targetLayer.name === layer.name) continue;
@@ -12,11 +12,11 @@ export interface SourceImport {
12
12
  const JS = /\.(?:[cm]?[jt]sx?)$/;
13
13
  const JS_EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", "/index.ts", "/index.js"];
14
14
 
15
- /** Imports that point inside the project. Imports of libraries and the standard library are left out. */
16
- export function importsOf(path: string, text: string): SourceImport[] {
15
+ /** Imports that point inside the project, among `files`. Imports of libraries and the standard library are left out. */
16
+ export function importsOf(path: string, text: string, files: readonly string[]): SourceImport[] {
17
17
  if (JS.test(path)) return jsImports(path, text);
18
18
  if (path.endsWith(".py")) return pythonImports(path, text);
19
- if (path.endsWith(".rs")) return rustImportCandidates(path, text).map((i) => ({ ...i, bySuffix: false }));
19
+ if (path.endsWith(".rs")) return rustImportCandidates(path, text, files).map((i) => ({ ...i, bySuffix: false }));
20
20
  if (/\.(?:c|h|cc|cpp|cxx|hh|hpp|hxx)$/.test(path)) return matches(text, /^\s*#\s*include\s+"([^"]+)"/gm, (target) => [target], true);
21
21
  if (path.endsWith(".go")) return goImports(text);
22
22
  if (/\.(?:java|kt)$/.test(path)) return matches(text, /^import\s+(?:static\s+)?([\w.]+)(?:\.\*)?;?\s*$/gm, (t) => [`${t.replace(/\./g, "/")}.java`, `${t.replace(/\./g, "/")}.kt`], true);
@@ -1,10 +1,14 @@
1
- /** Resolves Rust `use` paths inside a crate to candidate source files. */
1
+ /** Resolves Rust `use` paths, inside a crate and between a workspace's crates, to candidate source files. */
2
2
 
3
- /** Candidate files for each `use crate::…`, `use super::…` and `use self::…` path in a Rust file. */
4
- export function rustImportCandidates(path: string, text: string): Array<{ line: number; candidates: string[] }> {
3
+ /**
4
+ * Candidate files for each `use crate::…`, `use super::…` and `use self::…` path in a Rust file,
5
+ * and for each `use other_crate::…` that names a library crate among `files`.
6
+ */
7
+ export function rustImportCandidates(path: string, text: string, files: readonly string[]): Array<{ line: number; candidates: string[] }> {
5
8
  const root = crateRoot(path);
6
9
  if (root === null) return [];
7
10
  const own = moduleSegments(path, root);
11
+ const crates = libraryCrates(files);
8
12
  const results: Array<{ line: number; candidates: string[] }> = [];
9
13
  const pattern = /^\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/gms;
10
14
  for (const match of text.matchAll(pattern)) {
@@ -12,11 +16,29 @@ export function rustImportCandidates(path: string, text: string): Array<{ line:
12
16
  for (const segments of expandUse((match[1] ?? "").replace(/\s+/g, ""))) {
13
17
  const absolute = absoluteSegments(segments, own);
14
18
  if (absolute !== null) results.push({ line, candidates: candidatesFor(root, absolute) });
19
+ else {
20
+ const [head, ...rest] = segments;
21
+ const other = head === undefined ? undefined : crates.get(head);
22
+ if (other !== undefined && other !== root) results.push({ line, candidates: candidatesFor(other, rest) });
23
+ }
15
24
  }
16
25
  }
17
26
  return results;
18
27
  }
19
28
 
29
+ /**
30
+ * Each library crate in the project by the name code imports it as: the directory
31
+ * holding `src/lib.rs`, with hyphens as underscores, as Cargo names it by default.
32
+ */
33
+ function libraryCrates(files: readonly string[]): Map<string, string> {
34
+ const crates = new Map<string, string>();
35
+ for (const file of files) {
36
+ const match = /^(?:.*\/)?([^/]+)\/src\/lib\.rs$/.exec(file);
37
+ if (match !== null) crates.set((match[1] ?? "").replace(/-/g, "_"), file.slice(0, -"lib.rs".length));
38
+ }
39
+ return crates;
40
+ }
41
+
20
42
  /** The directory holding the crate root: everything up to and including `src/`. */
21
43
  function crateRoot(path: string): string | null {
22
44
  const index = path.lastIndexOf("src/");
@@ -11,7 +11,7 @@ export const MAX_FILE_WORDS = 2100;
11
11
  * as every fix added rules and nothing was removed; 0.0.14 cut them to this. Lower it when text is
12
12
  * cut. Raising it is the maintainer's decision, with the reason in the commit that raises it.
13
13
  */
14
- export const BUNDLE_WORD_BUDGET = 17605;
14
+ export const BUNDLE_WORD_BUDGET = 17725;
15
15
 
16
16
  /** Data the CLI reads or renders, rather than instructions an agent reads. */
17
17
  const NOT_INSTRUCTIONS = /(^|\/)(templates|notes)\//;
@@ -3,34 +3,29 @@ import { findSection, type MarkdownDoc } from "../markdown.ts";
3
3
  import { FEATURE_SECTIONS, FEATURE_STATUSES, statusLine, strayScenarioIds, type Feature } from "../project/features.ts";
4
4
  import { PATHS } from "../project/layout.ts";
5
5
  import type { ProjectDocs } from "../project/project_docs.ts";
6
- import { error, warning, type Problem } from "./problem.ts";
6
+ import { error, type Problem } from "./problem.ts";
7
7
 
8
- /** Each document's required sections, its size, its scenarios, and leftover template guides. */
8
+ /** Each document's required sections, its scenarios, and leftover template guides. */
9
9
 
10
10
  const VISION_SECTIONS = ["Problem", "Users", "Release"];
11
11
  const ARCHITECTURE_SECTIONS = ["Overview", "Ports", "Adapters", "Runtime"];
12
12
 
13
- /** Words past which a document stops being something a person reads in one sitting. */
14
- export const WORD_LIMITS = { vision: 1500, architecture: 3000, feature: 2500 } as const;
15
-
16
13
  const GUIDE = /(?<!`)<(?!!--|\/|https?:|br\b)[A-Za-z][^<>\n]*>(?!`)/;
17
14
  const PLACEHOLDER = /\{\{[a-z_]+\}\}/;
18
15
 
19
16
  export function documentProblems(docs: ProjectDocs): Problem[] {
20
17
  const problems: Problem[] = [];
21
- if (docs.vision !== undefined) problems.push(...shapeOf(PATHS.vision, docs.vision, VISION_SECTIONS, WORD_LIMITS.vision));
22
- if (docs.architecture !== undefined) problems.push(...shapeOf(PATHS.architecture, docs.architecture, ARCHITECTURE_SECTIONS, WORD_LIMITS.architecture));
18
+ if (docs.vision !== undefined) problems.push(...shapeOf(PATHS.vision, docs.vision, VISION_SECTIONS));
19
+ if (docs.architecture !== undefined) problems.push(...shapeOf(PATHS.architecture, docs.architecture, ARCHITECTURE_SECTIONS));
23
20
  for (const feature of docs.features) {
24
- problems.push(...shapeOf(feature.path, feature.doc, [...FEATURE_SECTIONS], WORD_LIMITS.feature), ...featureProblems(feature));
21
+ problems.push(...shapeOf(feature.path, feature.doc, [...FEATURE_SECTIONS]), ...featureProblems(feature));
25
22
  }
26
23
  problems.push(...duplicateScenarios(docs.features));
27
24
  return problems;
28
25
  }
29
26
 
30
- function shapeOf(path: string, doc: MarkdownDoc, sections: string[], limit: number): Problem[] {
27
+ function shapeOf(path: string, doc: MarkdownDoc, sections: string[]): Problem[] {
31
28
  const problems: Problem[] = sections.filter((title) => findSection(doc, title) === null).map((title) => error(path, undefined, "missing-section", `needs a ## ${title} section`));
32
- const words = doc.lines.filter((_, i) => !doc.inFence[i]).join(" ").split(/\s+/).filter((w) => w !== "").length;
33
- if (words > limit) problems.push(warning(path, undefined, "too-long", `${words} words, over the ${limit} a person reads in one sitting; cut what the agent decided and the user needn't read, or split it`));
34
29
  doc.lines.forEach((line, i) => {
35
30
  if (doc.inFence[i]) return;
36
31
  const withoutCode = line.replace(/`[^`]*`/g, "");
@@ -0,0 +1,21 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ import { PATHS } from "../project/layout.ts";
3
+ import type { ProjectDocs } from "../project/project_docs.ts";
4
+ import { warning, type Problem } from "./problem.ts";
5
+
6
+ /**
7
+ * Warns when a feature is built but `review.command` is still the one `cruze init`
8
+ * wrote: the foundation never added the project's test commands, so the code
9
+ * reviewer can only read the code. `initSetting` is the review template init renders.
10
+ */
11
+ export function reviewCommandProblems(docs: ProjectDocs, initSetting: string): Problem[] {
12
+ const command = docs.config?.config?.review.command;
13
+ if (command === undefined || !docs.features.some((feature) => feature.status === "built")) return [];
14
+ if (JSON.stringify(command) !== JSON.stringify(initCommand(initSetting))) return [];
15
+ return [warning(PATHS.config, undefined, "review-tests", "review.command is still the one cruze init wrote, so the code reviewer can't run the project's tests; add its test and lint commands as .agents/skills/cruze-formats/config.md shows")];
16
+ }
17
+
18
+ function initCommand(setting: string): unknown {
19
+ const parsed: unknown = parseYaml(setting);
20
+ return (parsed as { review?: { command?: unknown } } | null)?.review?.command;
21
+ }