@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
@@ -5,12 +5,14 @@ import { readProgress } from "../project/progress.ts";
5
5
  import type { ProjectView } from "../project/project_view.ts";
6
6
  import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.ts";
7
7
  import { changesOf, featureOf, type WorkItem } from "../project/work_items.ts";
8
+ import { compareVersions } from "../versions/cruze_version.ts";
9
+ import { FIRST_RELEASE } from "../versions/rule_versions.ts";
8
10
  import { isVerified } from "./verification.ts";
9
11
  import { activeChange } from "./work_status.ts";
10
12
 
11
13
  /** The Cruze step to run next, computed from the project's state and the current branch. */
12
14
 
13
- export type Step = "envision" | "architect" | "roadmap" | "plan" | "build" | "verify" | "land" | "rethink" | "switch-branch";
15
+ export type Step = "envision" | "architect" | "roadmap" | "plan" | "build" | "verify" | "land" | "rethink" | "realign" | "switch-branch";
14
16
 
15
17
  export interface NextStep {
16
18
  step: Step;
@@ -31,12 +33,27 @@ export function nextSteps(view: ProjectView, branch: string | null): NextReport
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: ProjectView): NextStep | null {
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: ProjectView): NextStep | null {
42
59
  const docs: Array<{ path: string; missing: NextStep; unapproved: NextStep; changed: NextStep }> = [
@@ -1,8 +1,10 @@
1
+ import { insideModuleMap } from "../check/check_source.ts";
1
2
  import { factsOf, isUnbuilt } from "../elements.ts";
3
+ import { matchesAny } from "../glob.ts";
2
4
  import { findIds, kindOf } from "../ids.ts";
3
5
  import type { MarkdownDoc } from "../markdown.ts";
4
6
  import type { Delta } from "../project/deltas.ts";
5
- import { isPlanned, readScope, readTasks, readTestPlan, type Scope } from "../project/plan_parts.ts";
7
+ import { isPlanned, readScope, readTasks, readTestPlan, type Scope, type Task } from "../project/plan_parts.ts";
6
8
  import type { ProjectView } from "../project/project_view.ts";
7
9
  import { error, warning, type Problem } from "./problem.ts";
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: ProjectView, doc: MarkdownDoc, delta: Delta, id: st
103
106
  function modulePathsOf(view: ProjectView, doc: MarkdownDoc, delta: Delta, id: string): string[] {
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: ProjectView, path: string, tasks: Task[], delta: { delta: Delta; doc: MarkdownDoc }): Problem[] {
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.ts";
7
7
  import type { ProjectView } from "../project/project_view.ts";
8
8
  import { error, warning, type Problem } from "./problem.ts";
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: ProjectView): Problem[] {
97
97
  function modulePaths(doc: MarkdownDoc, element: Parameters<typeof factsOf>[1]): string[] {
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: ProjectView): Problem[] {
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
  import type { ProjectView } from "../project/project_view.ts";
2
2
  import { livingProblems } from "./living_rules.ts";
3
3
  import { error, sortProblems, type Problem } from "./problem.ts";
4
- import { configProblems, roadmapProblems, templateLeftovers } from "./project_rules.ts";
4
+ import { configProblems, readmeProblems, roadmapProblems, templateLeftovers } from "./project_rules.ts";
5
5
  import { workItemProblems } from "./work_item_rules.ts";
6
6
 
7
7
  /** Every rule in the cruze-formats skill, applied to the whole project. */
@@ -16,6 +16,7 @@ export function validateProject(view: ProjectView): Problem[] {
16
16
  ...roadmapProblems(view),
17
17
  ...workItemProblems(view),
18
18
  ...templateLeftovers(view),
19
+ ...readmeProblems(view),
19
20
  ...approvalProblems,
20
21
  ...journalProblems,
21
22
  ]);
@@ -56,6 +56,9 @@ function itemProblems(view: ProjectView, item: WorkItem): Problem[] {
56
56
  }
57
57
 
58
58
  /** A feature's Changes table covers its deltas, and each change folder agrees with its row. */
59
+ /** More changes than this, and one design review can't hold the whole feature: contradictions slip through to plan. */
60
+ const MAX_CHANGES = 6;
61
+
59
62
  function featureScopeProblems(view: ProjectView, feature: WorkItem, delta: Delta): Problem[] {
60
63
  const rows = readChangesTable(feature.doc);
61
64
  if (rows.length === 0) return [warning(feature.path, undefined, "not-designed", "the feature has no changes yet; architect designs and splits it")];
@@ -64,6 +67,10 @@ function featureScopeProblems(view: ProjectView, feature: WorkItem, delta: Delta
64
67
  [...readProgress(feature.doc).changes].filter(([, state]) => state === "landed").map(([change]) => change),
65
68
  );
66
69
  const names = rows.map((row) => row.change);
70
+ const extra = rows[MAX_CHANGES];
71
+ if (extra !== undefined) {
72
+ problems.push(warning(feature.path, extra.line, "feature-size", `the feature has ${rows.length} changes, over ${MAX_CHANGES}; split it into features a single design review can hold`));
73
+ }
67
74
  for (const row of rows) {
68
75
  if (!/^\d\d-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(row.change)) problems.push(error(feature.path, row.line, "change-name", `change "${row.change}" must be named NN-slug`));
69
76
  if (names.indexOf(row.change) !== names.lastIndexOf(row.change)) problems.push(error(feature.path, row.line, "change-name", `change ${row.change} is listed twice`));
@@ -0,0 +1,40 @@
1
+ /** The Cruze package CI runs through `npx --package=<spec>`, kept at the version the project installed. */
2
+
3
+ export interface Repin {
4
+ text: string;
5
+ /** Each spec that now names the new version, as it reads after the edit. */
6
+ repinned: string[];
7
+ /** Specs Cruze doesn't know how to move, such as a branch or a local path, left as they were. */
8
+ unrecognised: string[];
9
+ }
10
+
11
+ const FORMS: Array<(spec: string, version: string) => string | null> = [
12
+ // The npm package: @printpractical/cruze@0.0.1
13
+ (spec, version) => (/^@printpractical\/cruze@[^/\s]+$/.test(spec) ? `@printpractical/cruze@${version}` : null),
14
+ // The release tarball: .../releases/download/v0.0.1/printpractical-cruze-0.0.1.tgz
15
+ (spec, version) => {
16
+ const m = /^(.*\/releases\/download\/)v[^/]+\/printpractical-cruze-[^/]+\.tgz$/.exec(spec);
17
+ return m === null ? null : `${m[1]}v${version}/printpractical-cruze-${version}.tgz`;
18
+ },
19
+ // The git repository at a release tag: github:PrintPractical/cruze#v0.0.1
20
+ (spec, version) => {
21
+ const m = /^(.*cruze(?:\.git)?)#v\d[^\s]*$/.exec(spec);
22
+ return m === null ? null : `${m[1]}#v${version}`;
23
+ },
24
+ ];
25
+
26
+ /** Moves every `--package=<spec>` that runs Cruze in a CI workflow to the version. */
27
+ export function repinCruze(ciText: string, version: string): Repin {
28
+ const repinned: string[] = [];
29
+ const unrecognised: string[] = [];
30
+ const text = ciText.replace(/--package=(\S+)(?=\s+cruze\b)/g, (whole, spec: string) => {
31
+ const moved = FORMS.map((form) => form(spec, version)).find((result) => result !== null);
32
+ if (moved === undefined || moved === null) {
33
+ if (!unrecognised.includes(spec)) unrecognised.push(spec);
34
+ return whole;
35
+ }
36
+ if (!repinned.includes(moved)) repinned.push(moved);
37
+ return `--package=${moved}`;
38
+ });
39
+ return { text, repinned, unrecognised };
40
+ }
@@ -0,0 +1,51 @@
1
+ /** Which Cruze a project runs: recorded in `.cruze/config.yaml` by `cruze install`, compared with the CLI. */
2
+
3
+ /** Orders two versions such as `0.0.1` and `0.1.0` by their numeric parts; a pre-release tag is ignored. */
4
+ export function compareVersions(a: string, b: string): number {
5
+ const parts = (v: string): number[] => (v.split("-")[0] ?? "").split(".").map((p) => Number.parseInt(p, 10) || 0);
6
+ const [x, y] = [parts(a), parts(b)];
7
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
8
+ const diff = (x[i] ?? 0) - (y[i] ?? 0);
9
+ if (diff !== 0) return diff;
10
+ }
11
+ return 0;
12
+ }
13
+
14
+ export type VersionSkew =
15
+ | { kind: "unrecorded"; message: string }
16
+ | { kind: "skills-older"; recorded: string; message: string }
17
+ | { kind: "cli-older"; recorded: string; message: string };
18
+
19
+ /** How the project's recorded Cruze differs from the CLI running now, or undefined when they match. */
20
+ export function versionSkew(cli: string, recorded: string | undefined): VersionSkew | undefined {
21
+ if (recorded === undefined) {
22
+ return { kind: "unrecorded", message: `the project doesn't record which Cruze its skills came from; run \`cruze install\` to install the skills of this CLI (${cli}) and record it` };
23
+ }
24
+ const order = compareVersions(recorded, cli);
25
+ if (order < 0) {
26
+ return { kind: "skills-older", recorded, message: `the project's skills are from Cruze ${recorded}, older than this CLI (${cli}); run \`cruze install\` to update them and the CI pin` };
27
+ }
28
+ if (order > 0) {
29
+ return { kind: "cli-older", recorded, message: `the project uses Cruze ${recorded}, newer than this CLI (${cli}); upgrade the CLI as the README's "Upgrading" section says` };
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ /**
35
+ * The config text with `cruze:` set to the version. The line is replaced in place, or added
36
+ * after `version:`, so the file's comments and layout survive.
37
+ */
38
+ export function withRecordedVersion(configText: string, version: string): string {
39
+ return withVersionLine(configText, "cruze", version);
40
+ }
41
+
42
+ /** The config text with a top-level version key, such as `cruze:` or `standards:`, set in place. */
43
+ export function withVersionLine(configText: string, key: "cruze" | "standards", version: string): string {
44
+ const line = `${key}: ${JSON.stringify(version)}`;
45
+ const existing = new RegExp(`^${key}:.*$`, "m");
46
+ if (existing.test(configText)) return configText.replace(existing, line);
47
+ const anchor = key === "standards" ? /^cruze:.*$/m.exec(configText) ?? /^version:.*$/m.exec(configText) : /^version:.*$/m.exec(configText);
48
+ if (anchor === null) return `${line}\n${configText}`;
49
+ const end = anchor.index + anchor[0].length;
50
+ return `${configText.slice(0, end)}\n${line}${configText.slice(end)}`;
51
+ }
@@ -0,0 +1,72 @@
1
+ import { compareVersions } from "./cruze_version.ts";
2
+
3
+ /**
4
+ * A realign note: one change in what Cruze expects of a project, shipped in
5
+ * `skills/realign/notes/<version>.md` so `realign` can bring existing code up to it.
6
+ */
7
+ export interface RealignNote {
8
+ id: string;
9
+ title: string;
10
+ version: string;
11
+ /** Whether existing code and documents must change, or only new work follows it. */
12
+ appliesTo: "existing" | "new";
13
+ /** Where the rule lives, such as a skill file and its heading. */
14
+ rule: string;
15
+ /** A CLI rule that finds it mechanically, as `<command>:<rule>`. */
16
+ detect?: string;
17
+ /** The question an auditor answers when no CLI rule can. */
18
+ ask?: string;
19
+ migrate?: string;
20
+ }
21
+
22
+ export interface ParsedNotes {
23
+ notes: RealignNote[];
24
+ problems: string[];
25
+ }
26
+
27
+ const APPLIES: Record<string, RealignNote["appliesTo"]> = { "existing code": "existing", "new work only": "new" };
28
+
29
+ /** Parses one version's notes file. Each note is a `### <id>: <title>` heading with facts under it. */
30
+ export function parseRealignNotes(version: string, text: string): ParsedNotes {
31
+ const notes: RealignNote[] = [];
32
+ const problems: string[] = [];
33
+ const sections = text.split(/^### /m).slice(1);
34
+ for (const section of sections) {
35
+ const [heading = "", ...body] = section.split("\n");
36
+ const match = /^([a-z0-9]+(?:-[a-z0-9]+)*): (.+)$/.exec(heading.trim());
37
+ if (match === null) {
38
+ problems.push(`${version}: "### ${heading.trim()}" must read "### <id>: <title>"`);
39
+ continue;
40
+ }
41
+ const [, id = "", title = ""] = match;
42
+ const fact = (key: string): string | undefined => body.map((line) => new RegExp(`^- ${key}: (.+)$`).exec(line)?.[1]?.trim()).find((v) => v !== undefined);
43
+ const applies = APPLIES[fact("Applies to") ?? ""];
44
+ const rule = fact("Rule");
45
+ const detect = fact("Detect")?.replace(/`/g, "");
46
+ const ask = fact("Ask");
47
+ const migrate = fact("Migrate");
48
+ if (applies === undefined) problems.push(`${version} ${id}: "- Applies to:" must be "existing code" or "new work only"`);
49
+ if (rule === undefined) problems.push(`${version} ${id}: needs "- Rule:"`);
50
+ if (detect !== undefined && !/^(check|validate):[a-z-]+$/.test(detect)) problems.push(`${version} ${id}: "- Detect:" names a rule as check:<rule> or validate:<rule>`);
51
+ if (applies === "existing" && detect === undefined && ask === undefined) problems.push(`${version} ${id}: a note for existing code needs "- Detect:" or "- Ask:"`);
52
+ if (applies === "existing" && migrate === undefined) problems.push(`${version} ${id}: a note for existing code needs "- Migrate:"`);
53
+ notes.push({
54
+ id,
55
+ title,
56
+ version,
57
+ appliesTo: applies ?? "new",
58
+ rule: rule ?? "",
59
+ ...(detect === undefined ? {} : { detect }),
60
+ ...(ask === undefined ? {} : { ask }),
61
+ ...(migrate === undefined ? {} : { migrate }),
62
+ });
63
+ }
64
+ const ids = notes.map((n) => n.id);
65
+ ids.filter((id, i) => ids.indexOf(id) !== i).forEach((id) => problems.push(`${version}: note ${id} appears twice`));
66
+ return { notes, problems };
67
+ }
68
+
69
+ /** Notes after the version the code meets, up to and including the version installed. */
70
+ export function notesBetween(notes: RealignNote[], standards: string, target: string): RealignNote[] {
71
+ return notes.filter((n) => compareVersions(n.version, standards) > 0 && compareVersions(n.version, target) <= 0);
72
+ }
@@ -0,0 +1,22 @@
1
+ import { compareVersions } from "./cruze_version.ts";
2
+
3
+ /** The first release of Cruze. A project that records no `standards:` meets its expectations. */
4
+ export const FIRST_RELEASE = "0.0.1";
5
+
6
+ /**
7
+ * The release that introduced each rule reporting on existing code, keyed as `<command>:<rule>`.
8
+ * A rule newer than the project's `standards:` still reports, but can't fail CI until the project realigns.
9
+ * Every entry needs a realign note in `skills/realign/notes/<version>.md`; the bundle lint checks it.
10
+ */
11
+ export const RULE_SINCE: Record<string, string> = {
12
+ "check:test-placement": "0.0.2",
13
+ "validate:outside-module-map": "0.0.2",
14
+ "validate:readme-overview": "0.0.2",
15
+ "validate:feature-size": "0.0.2",
16
+ };
17
+
18
+ /** Whether a rule arrived after the version the project's code meets, so it waits for `realign`. */
19
+ export function isPending(command: "check" | "validate", rule: string, standards: string): boolean {
20
+ const since = RULE_SINCE[`${command}:${rule}`];
21
+ return since !== undefined && compareVersions(since, standards) > 0;
22
+ }
@@ -0,0 +1,41 @@
1
+ import { parseDocument, type Document } from "yaml";
2
+
3
+ /**
4
+ * `check.exceptions` entries that `realign` adds for findings a prefactor change will fix,
5
+ * and that landing that change removes. Edited through the YAML document, so comments survive.
6
+ */
7
+
8
+ export interface Scheduled {
9
+ path: string;
10
+ note: string;
11
+ }
12
+
13
+ const scheduledReason = (version: string, note: string, ref: string): string => `realign ${version} (${note}): scheduled in ${ref}`;
14
+
15
+ /** The config text with an exception for each finding, naming the change that will fix it. */
16
+ export function withScheduledExceptions(configText: string, version: string, ref: string, findings: Scheduled[]): string {
17
+ if (findings.length === 0) return configText;
18
+ const doc = parseDocument(configText);
19
+ const listed = new Set(((doc.getIn(["check", "exceptions"]) as { toJSON(): Array<{ path?: string }> } | undefined)?.toJSON() ?? []).map((e) => e.path));
20
+ for (const finding of findings.filter((f, i) => !listed.has(f.path) && findings.findIndex((o) => o.path === f.path) === i)) {
21
+ doc.addIn(["check", "exceptions"], doc.createNode({ path: finding.path, reason: scheduledReason(version, finding.note, ref) }));
22
+ }
23
+ return render(doc);
24
+ }
25
+
26
+ /** The config text without the exceptions scheduled in a change, once it lands; unchanged when it had none. */
27
+ export function withoutScheduledExceptions(configText: string, ref: string): { text: string; removed: string[] } {
28
+ const doc = parseDocument(configText);
29
+ const exceptions = doc.getIn(["check", "exceptions"]) as { items: Array<{ toJSON(): { path?: string; reason?: string } }> } | undefined;
30
+ if (exceptions === undefined || !Array.isArray(exceptions.items)) return { text: configText, removed: [] };
31
+ const suffix = `: scheduled in ${ref}`;
32
+ const removed = exceptions.items.map((item) => item.toJSON()).filter((e) => e.reason?.endsWith(suffix)).map((e) => e.path ?? "");
33
+ if (removed.length === 0) return { text: configText, removed: [] };
34
+ exceptions.items = exceptions.items.filter((item) => !(item.toJSON().reason ?? "").endsWith(suffix));
35
+ return { text: render(doc), removed };
36
+ }
37
+
38
+ /** Renders without wrapping long lines or padding flow lists, so an edit changes only what it touches. */
39
+ function render(doc: Document): string {
40
+ return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
41
+ }
@@ -1,6 +1,10 @@
1
1
  # Cruze project configuration, created by cruze {{cruze_version}}.
2
2
  # The format is described in .agents/skills/cruze-formats/reference/config.md.
3
3
  version: 1
4
+ # The Cruze whose skills this project installed. `cruze install` keeps it current.
5
+ cruze: "{{cruze_version}}"
6
+ # The Cruze whose expectations the code meets. `realign` moves it forward.
7
+ standards: "{{cruze_version}}"
4
8
  project: {{project_name_quoted}}
5
9
 
6
10
  # Where work items are tracked. V1 supports markdown in the repository only.
@@ -25,9 +29,4 @@ layers: []
25
29
  # walking-skeleton change fills them in; `cruze land` copies them into AGENTS.md.
26
30
  commands: {}
27
31
 
28
- # How `cruze review` starts an agent in a fresh context to run a role, with the
29
- # prompt on standard input. The default lets Claude Code read the project and run
30
- # git and cruze. The verifier runs the system, so add the project's build and run
31
- # commands, such as "Bash(cargo:*)". For Codex, use ["codex", "exec", "-"].
32
- review:
33
- command: ["claude", "-p", "--allowedTools", "Read Grep Glob Bash(git diff:*) Bash(git log:*) Bash(git show:*) Bash(cruze:*)"]
32
+ {{review_setting}}
@@ -0,0 +1,7 @@
1
+ # How `cruze review` starts an agent in a fresh context to run a role, with the
2
+ # prompt on standard input. This lets Claude Code read the project and run git
3
+ # and cruze. The verifier runs the system, so add the project's build and run
4
+ # commands, such as "Bash(cargo:*)". Without review.command, your agent runs each
5
+ # role in a helper of its own.
6
+ review:
7
+ command: ["claude", "-p", "--allowedTools", "Read Grep Glob Bash(git diff:*) Bash(git log:*) Bash(git show:*) Bash(cruze:*)"]
@@ -0,0 +1,7 @@
1
+ # How `cruze review` starts an agent in a fresh context to run a role, with the
2
+ # prompt on standard input. Without it, your agent runs each role in a helper
3
+ # of its own. To have cruze review start one instead, set the command, such as
4
+ # ["codex", "exec", "-"] for Codex or ["opencode", "run", "--agent", "plan"] for
5
+ # OpenCode.
6
+ # review:
7
+ # command: ["codex", "exec", "-"]
@@ -1 +0,0 @@
1
- @AGENTS.md