@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.
- package/README.md +41 -17
- package/dist/adapters/inbound/cli/cli_context.js +1 -0
- package/dist/adapters/inbound/cli/commands/code.js +2 -1
- package/dist/adapters/inbound/cli/commands/install.js +4 -0
- package/dist/adapters/inbound/cli/commands/realign.js +28 -0
- package/dist/adapters/inbound/cli/commands/review.js +1 -1
- package/dist/adapters/inbound/cli/commands.js +3 -1
- package/dist/adapters/inbound/cli/parse_command.js +2 -0
- package/dist/adapters/inbound/cli/run_cli.js +9 -0
- package/dist/adapters/outbound/node_project_files.js +2 -1
- package/dist/adapters/outbound/process_agent_runner.js +8 -3
- package/dist/app/ports/agent_runner.js +5 -6
- package/dist/app/project_context.js +4 -2
- package/dist/app/use_cases/check_code.js +8 -3
- package/dist/app/use_cases/init_project.js +31 -3
- package/dist/app/use_cases/install_skills.js +8 -1
- package/dist/app/use_cases/journal_events.js +4 -3
- package/dist/app/use_cases/land_change.js +27 -6
- package/dist/app/use_cases/realign_done.js +45 -0
- package/dist/app/use_cases/realign_status.js +106 -0
- package/dist/app/use_cases/record_version.js +56 -0
- package/dist/app/use_cases/run_review.js +26 -12
- package/dist/app/use_cases/validate_project.js +10 -4
- package/dist/domain/check/check_source.js +23 -2
- package/dist/domain/config.js +18 -8
- package/dist/domain/journal.js +29 -5
- package/dist/domain/project/layout.js +1 -0
- package/dist/domain/scaffold.js +15 -1
- package/dist/domain/status/next_step.js +19 -2
- package/dist/domain/validation/plan_rules.js +18 -1
- package/dist/domain/validation/project_rules.js +17 -1
- package/dist/domain/validation/validate_project.js +2 -1
- package/dist/domain/validation/work_item_rules.js +7 -0
- package/dist/domain/versions/ci_pin.js +40 -0
- package/dist/domain/versions/cruze_version.js +51 -0
- package/dist/domain/versions/realign_notes.js +72 -0
- package/dist/domain/versions/rule_versions.js +22 -0
- package/dist/domain/versions/scheduled_exceptions.js +41 -0
- package/package.json +1 -1
- package/skills/architect/feature.md +4 -2
- package/skills/behavioural-testing/SKILL.md +6 -0
- package/skills/build/SKILL.md +1 -1
- package/skills/envision/project.md +4 -2
- package/skills/formats/reference/config.md +7 -3
- package/skills/formats/reference/work-items.md +1 -1
- package/skills/hexagonal-design/SKILL.md +7 -1
- package/skills/hexagonal-design/languages/rust.md +7 -2
- package/skills/land/SKILL.md +1 -1
- package/skills/next/SKILL.md +1 -0
- package/skills/plan/tasks.md +7 -3
- package/skills/plan/test-plan.md +1 -1
- package/skills/realign/SKILL.md +35 -0
- package/skills/realign/notes/0.0.2.md +69 -0
- package/skills/realign/notes/0.0.3.md +11 -0
- package/skills/realign/notes.md +30 -0
- package/skills/research/SKILL.md +2 -1
- package/skills/rethink/SKILL.md +3 -1
- package/skills/rethink/levels.md +1 -1
- package/skills/roles/SKILL.md +5 -2
- package/skills/roles/auditor.md +35 -0
- package/skills/roles/code-reviewer.md +4 -3
- package/skills/roles/design-reviewer.md +9 -6
- package/skills/roles/researcher.md +1 -0
- package/src/adapters/inbound/cli/cli_context.ts +1 -0
- package/src/adapters/inbound/cli/commands/code.ts +2 -1
- package/src/adapters/inbound/cli/commands/install.ts +4 -0
- package/src/adapters/inbound/cli/commands/realign.ts +28 -0
- package/src/adapters/inbound/cli/commands/review.ts +1 -1
- package/src/adapters/inbound/cli/commands.ts +3 -1
- package/src/adapters/inbound/cli/parse_command.ts +2 -0
- package/src/adapters/inbound/cli/run_cli.ts +9 -0
- package/src/adapters/outbound/node_project_files.ts +2 -1
- package/src/adapters/outbound/process_agent_runner.ts +8 -3
- package/src/app/ports/agent_runner.ts +5 -6
- package/src/app/project_context.ts +4 -2
- package/src/app/use_cases/check_code.ts +8 -3
- package/src/app/use_cases/init_project.ts +31 -3
- package/src/app/use_cases/install_skills.ts +8 -1
- package/src/app/use_cases/journal_events.ts +4 -3
- package/src/app/use_cases/land_change.ts +27 -6
- package/src/app/use_cases/realign_done.ts +45 -0
- package/src/app/use_cases/realign_status.ts +106 -0
- package/src/app/use_cases/record_version.ts +56 -0
- package/src/app/use_cases/run_review.ts +26 -12
- package/src/app/use_cases/validate_project.ts +10 -4
- package/src/domain/check/check_source.ts +23 -2
- package/src/domain/config.ts +18 -8
- package/src/domain/journal.ts +29 -5
- package/src/domain/project/layout.ts +1 -0
- package/src/domain/scaffold.ts +15 -1
- package/src/domain/status/next_step.ts +19 -2
- package/src/domain/validation/plan_rules.ts +18 -1
- package/src/domain/validation/project_rules.ts +17 -1
- package/src/domain/validation/validate_project.ts +2 -1
- package/src/domain/validation/work_item_rules.ts +7 -0
- package/src/domain/versions/ci_pin.ts +40 -0
- package/src/domain/versions/cruze_version.ts +51 -0
- package/src/domain/versions/realign_notes.ts +72 -0
- package/src/domain/versions/rule_versions.ts +22 -0
- package/src/domain/versions/scheduled_exceptions.ts +41 -0
- package/templates/init/config.yaml +5 -6
- package/templates/init/review/claude.yaml +7 -0
- package/templates/init/review/other.yaml +7 -0
- package/templates/init/CLAUDE.md +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
-
import { recordedEventProblems, type JournalEntry } from "../../domain/journal.ts";
|
|
2
|
+
import { recordedEventProblems, recordedFields, type JournalEntry } from "../../domain/journal.ts";
|
|
3
3
|
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
4
4
|
import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
|
|
5
5
|
|
|
@@ -8,13 +8,14 @@ export async function recordEvent(deps: ProjectDeps, event: string, fields: Reco
|
|
|
8
8
|
const problems = recordedEventProblems(event, fields);
|
|
9
9
|
if (problems.length > 0) throw new CruzeError("invalid-event", problems.join("; "));
|
|
10
10
|
let folder = ".cruze";
|
|
11
|
+
let recorded = recordedFields(fields);
|
|
11
12
|
if (item !== undefined) {
|
|
12
13
|
const resolved = resolveArtifact(await loadView(deps.files), item).item;
|
|
13
14
|
if (resolved === undefined) throw new CruzeError("not-found", `"${item}" is not a feature or change`);
|
|
14
15
|
folder = resolved.folder;
|
|
15
|
-
|
|
16
|
+
recorded = { ...recorded, item: resolved.ref };
|
|
16
17
|
}
|
|
17
|
-
return appendJournal(deps, folder, event, { ...
|
|
18
|
+
return appendJournal(deps, folder, event, { ...recorded, cruze: deps.bundle.version });
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export async function listEvents(deps: ProjectDeps, event?: string): Promise<JournalEntry[]> {
|
|
@@ -15,6 +15,7 @@ import { changesOf, featureOf, type WorkItem } from "../../domain/project/work_i
|
|
|
15
15
|
import { isVerified } from "../../domain/status/verification.ts";
|
|
16
16
|
import { activeChange } from "../../domain/status/work_status.ts";
|
|
17
17
|
import { validateProject } from "../../domain/validation/validate_project.ts";
|
|
18
|
+
import { withoutScheduledExceptions } from "../../domain/versions/scheduled_exceptions.ts";
|
|
18
19
|
import { appendJournal, loadView, withFiles, type ProjectDeps } from "../project_context.ts";
|
|
19
20
|
|
|
20
21
|
const AGENTS_FILE = "AGENTS.md";
|
|
@@ -26,6 +27,8 @@ export interface LandReport {
|
|
|
26
27
|
removed: string[];
|
|
27
28
|
restamped: string[];
|
|
28
29
|
finished: boolean;
|
|
30
|
+
/** Files whose `check.exceptions` entry `realign` scheduled in this change, removed now that it lands. */
|
|
31
|
+
unexcepted: string[];
|
|
29
32
|
archivedTo?: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -54,7 +57,9 @@ export async function landChange(deps: ProjectDeps, ref?: string, options: { ove
|
|
|
54
57
|
const landed = `${deps.clock.today()}${commit === null ? "" : ` (${commit})`}`;
|
|
55
58
|
const books = applyBookkeeping(view, change, source, landed, plan.files);
|
|
56
59
|
const merged = withFiles(view, plan.files);
|
|
57
|
-
|
|
60
|
+
const recorded = { ...(merged.approvals.get(source.folder) ?? { version: 1, approvals: [], merged: {} }), merged: plan.merged };
|
|
61
|
+
const checked = withFiles(merged, new Map([[approvalsPath(source.folder), serializeApprovals(recorded)]]));
|
|
62
|
+
requireStillValid(view, checked, [source.path, ...changesOf(view.items, source).map((c) => c.path)]);
|
|
58
63
|
|
|
59
64
|
const approvals = new Map<string, ApprovalsFile>();
|
|
60
65
|
const by = (await deps.repository.userName()) ?? "unknown";
|
|
@@ -81,8 +86,9 @@ export async function landChange(deps: ProjectDeps, ref?: string, options: { ove
|
|
|
81
86
|
const rendered = renderAgentsFile(agents, merged);
|
|
82
87
|
if (rendered !== agents) await deps.files.writeText(AGENTS_FILE, rendered);
|
|
83
88
|
}
|
|
89
|
+
const unexcepted = await clearScheduledExceptions(deps, [change.ref, source.ref]);
|
|
84
90
|
|
|
85
|
-
const report: LandReport = { change: change.ref, merged: plan.mergedIds, built: plan.built, removed: plan.removed, restamped: restamp, finished: books.finished };
|
|
91
|
+
const report: LandReport = { change: change.ref, merged: plan.mergedIds, built: plan.built, removed: plan.removed, restamped: restamp, finished: books.finished, unexcepted };
|
|
86
92
|
if (books.archive !== undefined) report.archivedTo = books.archive.to;
|
|
87
93
|
return report;
|
|
88
94
|
}
|
|
@@ -102,12 +108,27 @@ function requireReady(view: ProjectView, change: WorkItem, source: WorkItem): vo
|
|
|
102
108
|
if (reasons.length > 0) throw new CruzeError("not-ready", `${change.ref} cannot land yet:\n ${reasons.join("\n ")}`);
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
/** The merge may not introduce errors into the living docs. */
|
|
106
|
-
function requireStillValid(before: ProjectView, after: ProjectView): void {
|
|
111
|
+
/** The merge may not introduce errors into the living docs, or into the work it lands, such as a citation of an ID it removes. */
|
|
112
|
+
function requireStillValid(before: ProjectView, after: ProjectView, work: string[]): void {
|
|
107
113
|
const key = (p: { path: string; message: string }): string => `${p.path}|${p.message}`;
|
|
108
114
|
const existing = new Set(validateProject(before).map(key));
|
|
109
|
-
const
|
|
115
|
+
const watched = (path: string): boolean => path.startsWith("docs/") || work.includes(path);
|
|
116
|
+
const introduced = validateProject(after).filter((p) => p.severity === "error" && watched(p.path) && !existing.has(key(p)));
|
|
110
117
|
if (introduced.length > 0) {
|
|
111
|
-
throw new CruzeError("merge-invalid", `landing would
|
|
118
|
+
throw new CruzeError("merge-invalid", `landing would leave errors behind; nothing was written:\n ${introduced.map((p) => `${p.path}:${p.line ?? ""} ${p.message}`).join("\n ")}`);
|
|
112
119
|
}
|
|
113
120
|
}
|
|
121
|
+
|
|
122
|
+
/** Removes the exceptions `realign` scheduled in the landed work, so CI checks those files again. */
|
|
123
|
+
async function clearScheduledExceptions(deps: ProjectDeps, refs: string[]): Promise<string[]> {
|
|
124
|
+
let text = await deps.files.readText(PATHS.config);
|
|
125
|
+
if (text === undefined) return [];
|
|
126
|
+
const removed: string[] = [];
|
|
127
|
+
for (const ref of [...new Set(refs)]) {
|
|
128
|
+
const result = withoutScheduledExceptions(text, ref);
|
|
129
|
+
text = result.text;
|
|
130
|
+
removed.push(...result.removed);
|
|
131
|
+
}
|
|
132
|
+
if (removed.length > 0) await deps.files.writeText(PATHS.config, text);
|
|
133
|
+
return removed;
|
|
134
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
3
|
+
import { PATHS } from "../../domain/project/layout.ts";
|
|
4
|
+
import { withVersionLine } from "../../domain/versions/cruze_version.ts";
|
|
5
|
+
import { withScheduledExceptions } from "../../domain/versions/scheduled_exceptions.ts";
|
|
6
|
+
import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
|
|
7
|
+
import { realignStatus } from "./realign_status.ts";
|
|
8
|
+
|
|
9
|
+
export interface RealignDone {
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
/** The change that fixes what is still open, when anything is. */
|
|
13
|
+
scheduled?: string;
|
|
14
|
+
/** Files listed under `check.exceptions` until that change lands. */
|
|
15
|
+
excepted: string[];
|
|
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: ProjectDeps, options: { change?: string }): Promise<RealignDone> {
|
|
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: string | undefined;
|
|
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.ts";
|
|
2
|
+
import type { CruzeConfig } from "../../domain/config.ts";
|
|
3
|
+
import { PATHS } from "../../domain/project/layout.ts";
|
|
4
|
+
import type { ProjectView } from "../../domain/project/project_view.ts";
|
|
5
|
+
import { validateProject } from "../../domain/validation/validate_project.ts";
|
|
6
|
+
import { notesBetween, parseRealignNotes, type RealignNote } from "../../domain/versions/realign_notes.ts";
|
|
7
|
+
import { FIRST_RELEASE } from "../../domain/versions/rule_versions.ts";
|
|
8
|
+
import type { Bundle } from "../ports/bundle.ts";
|
|
9
|
+
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
10
|
+
import { checkCode } from "./check_code.ts";
|
|
11
|
+
|
|
12
|
+
export const REALIGN_SKILL = "realign";
|
|
13
|
+
|
|
14
|
+
export interface RealignFinding {
|
|
15
|
+
path: string;
|
|
16
|
+
line?: number;
|
|
17
|
+
message: string;
|
|
18
|
+
/** Waived by an earlier realign, so never raised again. */
|
|
19
|
+
waived: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RealignNoteReport extends RealignNote {
|
|
23
|
+
/** What the note's CLI rule reports now, for a note with `Detect`. */
|
|
24
|
+
findings: RealignFinding[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RealignStatus {
|
|
28
|
+
/** The version the code meets now, and the one the installed skills expect. */
|
|
29
|
+
standards: string;
|
|
30
|
+
target: string;
|
|
31
|
+
full: boolean;
|
|
32
|
+
/** Notes for existing code: those with findings to fix, waive or schedule, and those an auditor answers. */
|
|
33
|
+
notes: RealignNoteReport[];
|
|
34
|
+
/** Notes that change only new work, so nothing existing has to move. */
|
|
35
|
+
newWorkOnly: string[];
|
|
36
|
+
/** Mechanical findings not yet waived. */
|
|
37
|
+
open: number;
|
|
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: ProjectDeps, options: { full: boolean }): Promise<RealignStatus> {
|
|
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): RealignNoteReport => {
|
|
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: string, path: string): string {
|
|
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: ProjectView, version: string): CruzeConfig {
|
|
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: Bundle): Promise<RealignNote[]> {
|
|
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: ProjectView): Set<string> {
|
|
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.ts";
|
|
2
|
+
import { PATHS } from "../../domain/project/layout.ts";
|
|
3
|
+
import { repinCruze } from "../../domain/versions/ci_pin.ts";
|
|
4
|
+
import { compareVersions, versionSkew, withRecordedVersion } from "../../domain/versions/cruze_version.ts";
|
|
5
|
+
import type { ProjectFiles } from "../ports/project_files.ts";
|
|
6
|
+
|
|
7
|
+
export const CI_WORKFLOW = ".github/workflows/ci.yml";
|
|
8
|
+
|
|
9
|
+
export interface VersionRecord {
|
|
10
|
+
/** The version the project recorded before, when it recorded one. */
|
|
11
|
+
from?: string;
|
|
12
|
+
to: string;
|
|
13
|
+
/** The CI workflow's Cruze specs after the edit, and those left for the user to move. */
|
|
14
|
+
ci: { repinned: string[]; unrecognised: string[] };
|
|
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: ProjectFiles): Promise<string | undefined> {
|
|
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: ProjectFiles, version: string): Promise<string | undefined> {
|
|
25
|
+
const text = await files.readText(PATHS.config);
|
|
26
|
+
return text === undefined ? undefined : versionSkew(version, versionIn(text))?.message;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function versionIn(configText: string): string | undefined {
|
|
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: ProjectFiles, version: string): Promise<void> {
|
|
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: ProjectFiles, version: string): Promise<VersionRecord> {
|
|
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.ts";
|
|
|
4
4
|
import type { AgentRunner } from "../ports/agent_runner.ts";
|
|
5
5
|
import { loadView, type ProjectDeps } from "../project_context.ts";
|
|
6
6
|
|
|
7
|
-
export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher"] as const;
|
|
7
|
+
export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher", "auditor"] as const;
|
|
8
8
|
export type Role = (typeof ROLES)[number];
|
|
9
9
|
|
|
10
10
|
export interface ReviewRequest {
|
|
@@ -18,16 +18,19 @@ export interface ReviewRequest {
|
|
|
18
18
|
base?: string;
|
|
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
|
+
export type ReviewReport = { role: Role; item?: string; command?: string[] } & (
|
|
26
|
+
| { status: "reported"; report: string }
|
|
27
|
+
| { status: "run-in-helper"; instruction: string; prompt: string }
|
|
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: ProjectDeps & { runner: AgentRunner }, request: ReviewRequest): Promise<ReviewReport> {
|
|
33
36
|
if (!(ROLES as readonly string[]).includes(request.role)) throw new CruzeError("unknown-role", `the roles are ${ROLES.join(", ")}`);
|
|
@@ -55,12 +58,23 @@ export async function runReview(deps: ProjectDeps & { runner: AgentRunner }, req
|
|
|
55
58
|
context.map((line) => `- ${line}`).join("\n"),
|
|
56
59
|
].join("\n\n");
|
|
57
60
|
|
|
58
|
-
const
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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: string | undefined): string {
|
|
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: ProjectDeps, role: Role): Promise<string> {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { hasErrors, validateProject } from "../../domain/validation/validate_project.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { warning, type Problem } from "../../domain/validation/problem.ts";
|
|
3
|
+
import { PATHS } from "../../domain/project/layout.ts";
|
|
4
|
+
import { versionSkew } from "../../domain/versions/cruze_version.ts";
|
|
5
|
+
import type { Bundle } from "../ports/bundle.ts";
|
|
3
6
|
import { loadView } from "../project_context.ts";
|
|
4
7
|
import type { ProjectFiles } from "../ports/project_files.ts";
|
|
5
8
|
|
|
@@ -10,9 +13,12 @@ export interface ValidateReport {
|
|
|
10
13
|
problems: Problem[];
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
/** Checks every document in the project against the cruze-formats rules. */
|
|
14
|
-
export async function validate(deps: { files: ProjectFiles }): Promise<ValidateReport> {
|
|
15
|
-
const
|
|
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: { files: ProjectFiles; bundle: Bundle }): Promise<ValidateReport> {
|
|
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.ts";
|
|
|
6
6
|
export interface CheckFinding {
|
|
7
7
|
path: string;
|
|
8
8
|
line?: number;
|
|
9
|
-
rule: "layer" | "max-lines" | "max-types" | "outside-module-map" | "no-layer";
|
|
9
|
+
rule: "layer" | "max-lines" | "max-types" | "outside-module-map" | "no-layer" | "test-placement";
|
|
10
10
|
message: string;
|
|
11
11
|
/** Layer violations always fail; the rest fail in CI unless excepted. */
|
|
12
12
|
severity: "error" | "warning";
|
|
13
|
+
/** Set when the rule is newer than the project's `standards:`: it reports, but can't fail CI until the project realigns. */
|
|
14
|
+
pending?: true;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface CheckInput {
|
|
@@ -56,9 +58,28 @@ export function checkSource(input: CheckInput): CheckFinding[] {
|
|
|
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 (
|
|
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: string, modulePaths: string[]): boolean {
|
|
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: string): boolean {
|
|
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
|
+
}
|
package/src/domain/config.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { parse as parseYaml } from "yaml";
|
|
|
3
3
|
/** `.cruze/config.yaml`, as described in the cruze-formats skill. */
|
|
4
4
|
export interface CruzeConfig {
|
|
5
5
|
version: 1;
|
|
6
|
+
/** The Cruze version whose skills the project installed, recorded by `cruze install`. */
|
|
7
|
+
cruze?: string;
|
|
8
|
+
/** The Cruze version whose expectations the code meets, moved forward by `realign`. */
|
|
9
|
+
standards?: string;
|
|
6
10
|
project: string;
|
|
7
11
|
tracker: "markdown";
|
|
8
12
|
source: string[];
|
|
@@ -15,13 +19,13 @@ export interface CruzeConfig {
|
|
|
15
19
|
layers: Layer[];
|
|
16
20
|
/** Named project commands, such as `test: cargo test`, shown in AGENTS.md. */
|
|
17
21
|
commands: Record<string, string>;
|
|
18
|
-
/**
|
|
19
|
-
|
|
22
|
+
/**
|
|
23
|
+
* How `cruze review` starts an agent in a fresh context: the program and its arguments.
|
|
24
|
+
* Without one, the calling agent runs the role's prompt in a helper of its own.
|
|
25
|
+
*/
|
|
26
|
+
review: { command?: string[] };
|
|
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
|
export interface Layer {
|
|
26
30
|
name: string;
|
|
27
31
|
paths: string[];
|
|
@@ -43,6 +47,10 @@ export function parseConfig(text: string): ConfigResult {
|
|
|
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: string): ConfigResult {
|
|
|
87
95
|
}
|
|
88
96
|
}
|
|
89
97
|
const review = asRecord(root["review"]) ?? {};
|
|
90
|
-
const reviewCommand = review["command"] === undefined ?
|
|
91
|
-
if (reviewCommand
|
|
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: CruzeConfig = {
|
|
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: string): ConfigResult {
|
|
|
99
109
|
check: { maxLines: maxLines ?? 250, maxTypes: maxTypes ?? 5, exceptions },
|
|
100
110
|
layers,
|
|
101
111
|
commands,
|
|
102
|
-
review:
|
|
112
|
+
review: reviewCommand === undefined || reviewCommand.length === 0 ? {} : { command: reviewCommand },
|
|
103
113
|
};
|
|
104
114
|
return { config, problems };
|
|
105
115
|
}
|
package/src/domain/journal.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface JournalEntry {
|
|
|
9
9
|
|
|
10
10
|
/** Events other tools and skills record, with the fields each requires. */
|
|
11
11
|
export const RECORDED_EVENTS: Record<string, string[]> = {
|
|
12
|
-
rethink: ["level", "kind", "summary", "wrong", "
|
|
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: Record<string, string[]> = {
|
|
|
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: Record<string, string[]> = {
|
|
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: Record<string, string[]> = {
|
|
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: string): string {
|
|
29
43
|
return `${folder}/journal.jsonl`;
|
|
30
44
|
}
|
|
@@ -53,10 +67,20 @@ export function serializeEntry(entry: JournalEntry): string {
|
|
|
53
67
|
export function recordedEventProblems(event: string, fields: Record<string, string>): string[] {
|
|
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
|
|
57
|
-
|
|
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
|
|
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: Record<string, string>): Record<string, string | number> {
|
|
85
|
+
return Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, COUNT_FIELDS.includes(key) ? Number(value) : value]));
|
|
86
|
+
}
|
package/src/domain/scaffold.ts
CHANGED
|
@@ -11,12 +11,26 @@ export const INIT_SCAFFOLD: readonly ScaffoldEntry[] = [
|
|
|
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"] as const;
|
|
24
|
+
export type InitAgent = (typeof INIT_AGENTS)[number];
|
|
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: InitAgent): string {
|
|
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: string, values: Record<string, string>): string {
|
|
22
36
|
return template.replace(/\{\{([a-z_]+)\}\}/g, (placeholder, key: string) => {
|