@printpractical/cruze 0.0.8 → 0.0.9
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 +4 -4
- package/dist/adapters/inbound/cli/cli_context.js +2 -0
- package/dist/adapters/inbound/cli/commands/code.js +2 -1
- package/dist/adapters/inbound/cli/commands/show.js +6 -1
- package/dist/adapters/outbound/git_repository.js +40 -7
- package/dist/adapters/outbound/project_root.js +13 -0
- package/dist/app/use_cases/project_status.js +4 -1
- package/dist/app/use_cases/show_scope.js +3 -1
- package/dist/domain/check/budget_text.js +43 -6
- package/dist/domain/check/check_source.js +2 -2
- package/dist/domain/config.js +12 -0
- package/dist/domain/edits/agents_file.js +6 -1
- package/dist/domain/journal.js +13 -2
- package/dist/domain/project/scope_view.js +20 -4
- package/dist/domain/status/next_step.js +25 -2
- package/dist/domain/status/verification.js +12 -0
- package/dist/domain/validation/plan_rules.js +1 -1
- package/dist/main.js +8 -4
- package/package.json +1 -1
- package/skills/architect/SKILL.md +4 -1
- package/skills/build/SKILL.md +2 -1
- package/skills/formats/reference/architecture.md +1 -1
- package/skills/formats/reference/config.md +9 -4
- package/skills/hexagonal-design/SKILL.md +2 -2
- package/skills/plan/SKILL.md +3 -3
- package/skills/plan/tasks.md +1 -1
- package/skills/realign/SKILL.md +1 -1
- package/skills/realign/notes/0.0.8.md +1 -1
- package/skills/realign/notes/0.0.9.md +59 -0
- package/skills/realign/notes.md +1 -0
- package/skills/rethink/SKILL.md +2 -2
- package/skills/rethink/levels.md +2 -2
- package/skills/roles/SKILL.md +1 -1
- package/skills/roles/code-reviewer.md +3 -2
- package/skills/roles/design-reviewer.md +13 -1
- package/skills/roles/verifier.md +2 -0
- package/skills/verify/SKILL.md +3 -3
- package/src/adapters/inbound/cli/cli_context.ts +2 -0
- package/src/adapters/inbound/cli/commands/code.ts +2 -1
- package/src/adapters/inbound/cli/commands/show.ts +6 -1
- package/src/adapters/outbound/git_repository.ts +40 -7
- package/src/adapters/outbound/project_root.ts +13 -0
- package/src/app/use_cases/project_status.ts +4 -1
- package/src/app/use_cases/show_scope.ts +3 -1
- package/src/domain/check/budget_text.ts +43 -6
- package/src/domain/check/check_source.ts +2 -2
- package/src/domain/config.ts +12 -0
- package/src/domain/edits/agents_file.ts +6 -1
- package/src/domain/journal.ts +13 -2
- package/src/domain/project/scope_view.ts +20 -4
- package/src/domain/status/next_step.ts +25 -2
- package/src/domain/status/verification.ts +12 -0
- package/src/domain/validation/plan_rules.ts +1 -1
- package/src/main.ts +8 -4
- package/templates/init/config.yaml +5 -4
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.
|
|
13
|
+
Version 0.0.9 is the ninth preview. Designs now say how work flows and recovers: the design reviewer checks full queues, fairness, recovery that fails and cancellation, and `references:` names the specifications a design must follow. `cruze next` sends a change verify sent back to the rethink it needs, file budgets are a backstop rather than a size to design to, and every step reads a change's scope through `cruze show` instead of the living docs whole. Early development. The CLI is complete for the V1 lifecycle. The document formats ship as the `cruze-formats` skill, the standards an agent designs and codes to ship as knowledge skills, and the whole workflow, from explore to land, ships as workflow skills. Claude Code is the agent tested so far.
|
|
14
14
|
|
|
15
15
|
## Why
|
|
16
16
|
|
|
@@ -35,7 +35,7 @@ The skills run `cruze` commands, so the agent needs `cruze` on its path. Install
|
|
|
35
35
|
From a GitHub Release, without npm. Each release attaches the packed package:
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.
|
|
38
|
+
npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.9/printpractical-cruze-0.0.9.tgz
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
From npm, once a version is published there:
|
|
@@ -47,7 +47,7 @@ npm install -g @printpractical/cruze
|
|
|
47
47
|
From the git repository, at a tag or `#main`. This needs git and access to the repository. npm 11 needs `--install-links` for a global git install, and the first `cruze` command builds the CLI, which takes a moment:
|
|
48
48
|
|
|
49
49
|
```sh
|
|
50
|
-
npm install -g --install-links github:PrintPractical/cruze#v0.0.
|
|
50
|
+
npm install -g --install-links github:PrintPractical/cruze#v0.0.9
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
### 2. Set up a repository
|
|
@@ -61,7 +61,7 @@ cruze init
|
|
|
61
61
|
When you didn't install from npm, tell `init` where Cruze came from, so the CI it writes runs the same source instead of the npm package:
|
|
62
62
|
|
|
63
63
|
```sh
|
|
64
|
-
cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.
|
|
64
|
+
cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.9/printpractical-cruze-0.0.9.tgz
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
`init` asks for the project name, and which coding agent you use: `claude` for Claude Code, or `other` for any agent that reads `AGENTS.md` and `.agents/skills/`, such as Codex or OpenCode. It doesn't ask when you pass `--agent claude` or `--agent other`, or when the repository already has `CLAUDE.md` or `.claude/`. With `--yes`, or without a terminal, it sets up Claude Code. Then it creates:
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
1
2
|
import { checkCode, traceTests } from "../../../../app/use_cases/check_code.js";
|
|
2
3
|
import { landChange } from "../../../../app/use_cases/land_change.js";
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
export async function runCheck(context , args , options ) {
|
|
6
|
-
const files = [...options.file, ...args];
|
|
7
|
+
const files = [...options.file, ...args].map((file) => posix.normalize(posix.join(context.workingDirectory, file)));
|
|
7
8
|
const report = await checkCode(context, { ci: options.ci, ...(files.length === 0 ? {} : { files }) });
|
|
8
9
|
const lines = report.findings.map((f) => `${f.severity} ${f.path}${f.line === undefined ? "" : `:${f.line}`} ${f.message} [${f.rule}]${f.pending === true ? " (pending realign)" : ""}`);
|
|
9
10
|
lines.push(`Checked ${report.checked} file(s): ${report.passed ? "passed" : "failed"}.`);
|
|
@@ -14,9 +14,14 @@ function render(report ) {
|
|
|
14
14
|
lines.push("## Elements", "", ...blocks(report.elements), "## Scenarios", "", ...blocks(report.scenarios));
|
|
15
15
|
lines.push("## Cited, not shown", "");
|
|
16
16
|
lines.push(...(report.cited.length === 0 ? ["None."] : report.cited.map((c) => `- ${c.id}: ${c.title} (${c.source})`)), "");
|
|
17
|
-
lines.push("## Settled decisions", "", ...(report.decisions.length === 0 ? ["None."] : report.decisions), "");
|
|
17
|
+
if (report.feature === undefined) lines.push("## Settled decisions", "", ...(report.decisions.length === 0 ? ["None."] : report.decisions), "");
|
|
18
|
+
else {
|
|
19
|
+
lines.push("## Settled decisions of the feature", "", ...(report.decisions.length === 0 ? ["None."] : report.decisions), "");
|
|
20
|
+
lines.push("## Settled decisions of the change", "", ...(report.changeDecisions.length === 0 ? ["None."] : report.changeDecisions), "");
|
|
21
|
+
}
|
|
18
22
|
lines.push("## Dispositions", "");
|
|
19
23
|
lines.push(...(report.dispositions.length === 0 ? ["None."] : report.dispositions.map((d) => `- [${d.disposition}, ${d.review}] ${d.finding}: ${d.reason}`)), "");
|
|
24
|
+
if (report.references.length > 0) lines.push("## References", "", ...report.references.map((r) => `- \`${r.path}\`: ${r.use}`), "");
|
|
20
25
|
if (report.missing.length > 0) lines.push("## Missing", "", ...report.missing.map((id) => `- ${id} is defined nowhere`), "");
|
|
21
26
|
return lines.join("\n").trimEnd();
|
|
22
27
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
|
|
4
4
|
|
|
@@ -37,13 +37,24 @@ export class GitRepository {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async filesOnBranch(branch , dir ) {
|
|
40
|
-
const files = new Map ();
|
|
41
40
|
const listing = await this.git("ls-tree", "-r", "--name-only", branch, "--", dir);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
return
|
|
41
|
+
const paths = (listing ?? "").split("\n").filter((p) => p !== "");
|
|
42
|
+
if (paths.length === 0) return new Map();
|
|
43
|
+
// One git process for every file: a process per file took up to two minutes on a project with 26 branches.
|
|
44
|
+
const blobs = await this.catFiles(paths.map((path) => `${branch}:${path}`));
|
|
45
|
+
return new Map(paths.flatMap((path, i) => (blobs[i] === undefined ? [] : [[path, blobs[i]] ])));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The contents of each object, in order, through one `git cat-file --batch`; undefined for one that is missing. */
|
|
49
|
+
catFiles(objects ) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const child = spawn("git", ["cat-file", "--batch"], { cwd: this.root, stdio: ["pipe", "pipe", "ignore"] });
|
|
52
|
+
const chunks = [];
|
|
53
|
+
child.stdout.on("data", (chunk ) => chunks.push(chunk));
|
|
54
|
+
child.on("error", () => resolve(objects.map(() => undefined)));
|
|
55
|
+
child.on("close", () => resolve(parseBatch(Buffer.concat(chunks), objects.length)));
|
|
56
|
+
child.stdin.end(`${objects.join("\n")}\n`);
|
|
57
|
+
});
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
async git(...args ) {
|
|
@@ -55,3 +66,25 @@ export class GitRepository {
|
|
|
55
66
|
}
|
|
56
67
|
}
|
|
57
68
|
}
|
|
69
|
+
|
|
70
|
+
/** Splits `git cat-file --batch` output: a `<sha> <type> <size>` header, the bytes and a newline, or `<name> missing`. */
|
|
71
|
+
function parseBatch(output , count ) {
|
|
72
|
+
const results = [];
|
|
73
|
+
let at = 0;
|
|
74
|
+
while (results.length < count && at < output.length) {
|
|
75
|
+
const headerEnd = output.indexOf(10, at);
|
|
76
|
+
if (headerEnd === -1) break;
|
|
77
|
+
const header = output.subarray(at, headerEnd).toString("utf8");
|
|
78
|
+
const size = /^[0-9a-f]+ \w+ (\d+)$/.exec(header)?.[1];
|
|
79
|
+
if (size === undefined) {
|
|
80
|
+
results.push(undefined);
|
|
81
|
+
at = headerEnd + 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const start = headerEnd + 1;
|
|
85
|
+
results.push(output.subarray(start, start + Number(size)).toString("utf8"));
|
|
86
|
+
at = start + Number(size) + 1;
|
|
87
|
+
}
|
|
88
|
+
while (results.length < count) results.push(undefined);
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** The nearest directory at or above `start` that holds `.cruze/config.yaml`, or undefined when none does. */
|
|
5
|
+
export function findProjectRoot(start ) {
|
|
6
|
+
let dir = start;
|
|
7
|
+
for (;;) {
|
|
8
|
+
if (existsSync(join(dir, ".cruze", "config.yaml"))) return dir;
|
|
9
|
+
const parent = dirname(dir);
|
|
10
|
+
if (parent === dir) return undefined;
|
|
11
|
+
dir = parent;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
+
import { PATHS } from "../../domain/project/layout.js";
|
|
2
3
|
import { readProgress } from "../../domain/project/progress.js";
|
|
3
4
|
import { buildProjectView } from "../../domain/project/project_view.js";
|
|
4
5
|
import { findWorkItem, } from "../../domain/project/work_items.js";
|
|
@@ -59,7 +60,9 @@ export async function findOverlaps(deps , ref )
|
|
|
59
60
|
const mine = touchedIds(view, change);
|
|
60
61
|
const overlaps = [];
|
|
61
62
|
for (const other of await deps.repository.otherBranches()) {
|
|
62
|
-
|
|
63
|
+
// Landed work is archived, so a branch's work in flight is under features/ and changes/ only.
|
|
64
|
+
const inFlight = [...(await deps.repository.filesOnBranch(other, PATHS.featuresDir)), ...(await deps.repository.filesOnBranch(other, PATHS.changesDir))];
|
|
65
|
+
const theirs = buildProjectView(new Map([...view.snapshot].filter(([p]) => p.startsWith("docs/")).concat(inFlight)));
|
|
63
66
|
// Work in flight on a branch is the change bound to it that hasn't landed, there or here.
|
|
64
67
|
const boundThere = theirs.items.filter((i) => {
|
|
65
68
|
const bound = readProgress(i.doc).branch;
|
|
@@ -14,6 +14,8 @@ import { listEvents } from "./journal_events.js";
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -31,5 +33,5 @@ export async function showScope(deps , ref )
|
|
|
31
33
|
reason: String(entry["reason"] ?? ""),
|
|
32
34
|
review: String(entry["review"] ?? ""),
|
|
33
35
|
}));
|
|
34
|
-
return { ...scope, dispositions };
|
|
36
|
+
return { ...scope, dispositions, references: view.config?.config?.references ?? [] };
|
|
35
37
|
}
|
|
@@ -1,24 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The part of a source file its budgets measure. Budgets apply to source, not tests, so a Rust
|
|
3
|
-
* file's
|
|
3
|
+
* file's test-only modules are left out: rustfmt closes a top-level module with `}` in column 0.
|
|
4
|
+
* A module is test-only when its `cfg` needs `test`, as `#[cfg(test)]` or
|
|
5
|
+
* `#[cfg(all(test, feature = "schema"))]` do; other attributes may sit between the two.
|
|
4
6
|
*/
|
|
5
7
|
export function budgetedText(path , text ) {
|
|
6
8
|
if (!path.endsWith(".rs")) return text;
|
|
7
9
|
const lines = text.split("\n");
|
|
8
10
|
const kept = [];
|
|
9
11
|
for (let i = 0; i < lines.length; i++) {
|
|
10
|
-
const
|
|
11
|
-
if (
|
|
12
|
+
const start = testModuleStart(lines, i);
|
|
13
|
+
if (start === null) {
|
|
12
14
|
kept.push(lines[i] ?? "");
|
|
13
15
|
continue;
|
|
14
16
|
}
|
|
15
|
-
if (
|
|
16
|
-
i
|
|
17
|
+
if ((lines[start] ?? "").trimEnd().endsWith(";")) {
|
|
18
|
+
i = start;
|
|
17
19
|
continue;
|
|
18
20
|
}
|
|
19
|
-
let end =
|
|
21
|
+
let end = start + 1;
|
|
20
22
|
while (end < lines.length && lines[end] !== "}") end++;
|
|
21
23
|
i = end;
|
|
22
24
|
}
|
|
23
25
|
return kept.join("\n");
|
|
24
26
|
}
|
|
27
|
+
|
|
28
|
+
/** The line of the `mod` a test-only `cfg` at `index` gates, or null when it gates no module. */
|
|
29
|
+
function testModuleStart(lines , index ) {
|
|
30
|
+
if (!needsTest(lines[index]?.trim() ?? "")) return null;
|
|
31
|
+
let next = index + 1;
|
|
32
|
+
while (/^\s*#\[/.test(lines[next] ?? "")) next++;
|
|
33
|
+
return /^(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+\s*[{;]/.test(lines[next] ?? "") ? next : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function needsTest(attribute ) {
|
|
37
|
+
const predicate = /^#\[cfg\((.*)\)\]$/.exec(attribute)?.[1]?.trim();
|
|
38
|
+
if (predicate === undefined) return false;
|
|
39
|
+
if (predicate === "test") return true;
|
|
40
|
+
const all = /^all\((.*)\)$/.exec(predicate)?.[1];
|
|
41
|
+
return all !== undefined && topLevelArguments(all).includes("test");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Splits `a, b(c, d), e` at its top-level commas. */
|
|
45
|
+
function topLevelArguments(text ) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let current = "";
|
|
49
|
+
for (const char of text) {
|
|
50
|
+
if (char === "," && depth === 0) {
|
|
51
|
+
parts.push(current.trim());
|
|
52
|
+
current = "";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === "(") depth++;
|
|
56
|
+
if (char === ")") depth--;
|
|
57
|
+
current += char;
|
|
58
|
+
}
|
|
59
|
+
parts.push(current.trim());
|
|
60
|
+
return parts;
|
|
61
|
+
}
|
|
@@ -55,11 +55,11 @@ export function checkSource(input ) {
|
|
|
55
55
|
const lines = budgeted.split("\n").length - (budgeted.endsWith("\n") ? 1 : 0);
|
|
56
56
|
if (!excepted && lines > config.check.maxLines) {
|
|
57
57
|
const measured = budgeted === text ? "" : ", not counting its inline tests";
|
|
58
|
-
findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines${measured}, over the budget of ${config.check.maxLines}; split it
|
|
58
|
+
findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines${measured}, over the budget of ${config.check.maxLines}; split it if it holds a second responsibility, or list it under check.exceptions` });
|
|
59
59
|
}
|
|
60
60
|
const types = countTypes(path, budgeted);
|
|
61
61
|
if (!excepted && types !== null && types > config.check.maxTypes) {
|
|
62
|
-
findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
|
|
62
|
+
findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}; split it if it holds a second responsibility, or list it under check.exceptions` });
|
|
63
63
|
}
|
|
64
64
|
if (!excepted && isRustTestFileInSource(path)) {
|
|
65
65
|
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/" });
|
package/dist/domain/config.js
CHANGED
|
@@ -26,6 +26,8 @@ import { parse as parseYaml } from "yaml";
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
|
|
30
|
+
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
/** Which review findings the user decides: only the exceptions, or every one. */
|
|
@@ -132,6 +134,15 @@ export function parseConfig(text ) {
|
|
|
132
134
|
}),
|
|
133
135
|
};
|
|
134
136
|
|
|
137
|
+
const references = (Array.isArray(root["references"]) ? root["references"] : root["references"] === undefined ? [] : [null]).flatMap((entry , i ) => {
|
|
138
|
+
const record = asRecord(entry);
|
|
139
|
+
if (typeof record?.["path"] !== "string" || record["path"].trim() === "" || typeof record["use"] !== "string" || record["use"].trim() === "") {
|
|
140
|
+
problems.push(`references[${i}] needs a path and a non-empty use`);
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
return [{ path: record["path"], use: record["use"] }];
|
|
144
|
+
});
|
|
145
|
+
|
|
135
146
|
const config = {
|
|
136
147
|
version: 1,
|
|
137
148
|
...(typeof cruze === "string" && cruze.trim() !== "" ? { cruze } : {}),
|
|
@@ -148,6 +159,7 @@ export function parseConfig(text ) {
|
|
|
148
159
|
decide: decide === "all" ? "all" : "exceptions",
|
|
149
160
|
},
|
|
150
161
|
changes,
|
|
162
|
+
references,
|
|
151
163
|
};
|
|
152
164
|
return { config, problems };
|
|
153
165
|
}
|
|
@@ -19,9 +19,14 @@ export function renderAgentsFile(text , view ) {
|
|
|
19
19
|
return next;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The modules whose code exists: those built, and those a land built before a rethink set them
|
|
24
|
+
* back to `planned` for a change to rebuild, since their directories are still there.
|
|
25
|
+
*/
|
|
22
26
|
function builtModules(view ) {
|
|
27
|
+
const everBuilt = new Set(view.journal.filter((e) => e.event === "land").flatMap((e) => (Array.isArray(e["built"]) ? (e["built"] ).map(String) : [])));
|
|
23
28
|
return [...view.living.elements.values()]
|
|
24
|
-
.filter(({ element, doc }) => element.kind === "MOD" && statusOf(doc, element) === "built")
|
|
29
|
+
.filter(({ element, doc }) => element.kind === "MOD" && (statusOf(doc, element) === "built" || everBuilt.has(element.id)))
|
|
25
30
|
.map(({ element, doc }) => {
|
|
26
31
|
const facts = factsOf(doc, element);
|
|
27
32
|
const paths = (facts.get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => `\`${m[1] ?? ""}\``));
|
package/dist/domain/journal.js
CHANGED
|
@@ -23,6 +23,7 @@ const OPTIONAL_FIELDS = {
|
|
|
23
23
|
rethink: ["missed_by"],
|
|
24
24
|
review: ["nits", "checked_by"],
|
|
25
25
|
disposition: ["decided"],
|
|
26
|
+
verification: ["back_to"],
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
/** The workflow steps and checks that find or miss a problem, so a retro can count them. */
|
|
@@ -35,11 +36,21 @@ export const FIELD_VALUES = {
|
|
|
35
36
|
decided: ["agent", "user"],
|
|
36
37
|
review: ["design", "plan", "code", "realign", "improve"],
|
|
37
38
|
result: ["accepted", "sent-back"],
|
|
39
|
+
back_to: ["build", "rethink"],
|
|
38
40
|
checked_by: ["agent", "reviewer"],
|
|
39
41
|
found_by: STEPS,
|
|
40
42
|
missed_by: STEPS,
|
|
41
43
|
};
|
|
42
44
|
|
|
45
|
+
/** A defect rethink must say which step missed it, and a sent-back verification where the change goes back to. */
|
|
46
|
+
function conditionallyRequired(event , fields ) {
|
|
47
|
+
if (event === "rethink" && fields["kind"] === "defect") return ["missed_by"];
|
|
48
|
+
if (event === "verification" && fields["result"] === "sent-back") return ["back_to"];
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CONDITION_NOTES = { missed_by: " for a defect", back_to: " when the change is sent back" };
|
|
53
|
+
|
|
43
54
|
/** Fields recorded as numbers rather than text. */
|
|
44
55
|
const COUNT_FIELDS = ["round", "blockers", "concerns", "nits"];
|
|
45
56
|
|
|
@@ -71,8 +82,8 @@ export function serializeEntry(entry ) {
|
|
|
71
82
|
export function recordedEventProblems(event , fields ) {
|
|
72
83
|
const required = RECORDED_EVENTS[event];
|
|
73
84
|
if (required === undefined) return [`unknown event "${event}"; recordable events: ${Object.keys(RECORDED_EVENTS).join(", ")}`];
|
|
74
|
-
const needed =
|
|
75
|
-
const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${field
|
|
85
|
+
const needed = [...required, ...conditionallyRequired(event, fields)];
|
|
86
|
+
const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${CONDITION_NOTES[field] ?? ""}`);
|
|
76
87
|
const known = [...required, ...(OPTIONAL_FIELDS[event] ?? [])];
|
|
77
88
|
for (const field of known) {
|
|
78
89
|
const value = fields[field];
|
|
@@ -36,8 +36,10 @@ import { featureOf, } from "./work_items.js";
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
|
|
40
40
|
|
|
41
|
+
|
|
42
|
+
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
|
|
@@ -66,7 +68,7 @@ export function scopeView(view , item ) {
|
|
|
66
68
|
return [shown];
|
|
67
69
|
});
|
|
68
70
|
const elements = show(wanted.elements);
|
|
69
|
-
const scenarios = show(wanted.scenarios);
|
|
71
|
+
const scenarios = withoutRepeatedScenarios(show(wanted.scenarios));
|
|
70
72
|
const shownIds = new Set([...elements, ...scenarios].map((e) => e.id));
|
|
71
73
|
const cited = [...new Set([...elements, ...scenarios].flatMap((e) => findIds(e.body.join("\n"))))]
|
|
72
74
|
.filter((id) => !shownIds.has(id))
|
|
@@ -74,12 +76,26 @@ export function scopeView(view , item ) {
|
|
|
74
76
|
const found = locate(id);
|
|
75
77
|
return found === undefined ? [] : [{ id, title: found.element.title, source: `${found.path}:${found.element.start + 1}` }];
|
|
76
78
|
});
|
|
77
|
-
const decisions =
|
|
78
|
-
const
|
|
79
|
+
const decisions = decisionLines((feature ?? item).doc);
|
|
80
|
+
const changeDecisions = feature === undefined ? [] : decisionLines(item.doc);
|
|
81
|
+
const result = { ref: item.ref, kind: item.kind, elements, scenarios, cited, decisions, changeDecisions, missing };
|
|
79
82
|
if (feature !== undefined) result.feature = feature.ref;
|
|
80
83
|
return result;
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A requirement shown beside its own scenarios keeps only its own text: its body would repeat
|
|
88
|
+
* each of them, which made up a third of one change's output.
|
|
89
|
+
*/
|
|
90
|
+
function withoutRepeatedScenarios(shown ) {
|
|
91
|
+
const ids = new Set(shown.map((e) => e.id));
|
|
92
|
+
return shown.map((element) => {
|
|
93
|
+
if (!element.id.startsWith("REQ-")) return element;
|
|
94
|
+
const first = element.body.findIndex((line, i) => i > 0 && /^#{2,6} /.test(line) && findIds(line).some((id) => ids.has(id)));
|
|
95
|
+
return first === -1 ? element : { ...element, body: element.body.slice(0, first) };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
/** A change shows what its scope builds, removes and delivers. */
|
|
84
100
|
function changeIds(change ) {
|
|
85
101
|
const scope = readScope(change.doc);
|
|
@@ -7,7 +7,7 @@ import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.js";
|
|
|
7
7
|
import { changesOf, featureOf, } from "../project/work_items.js";
|
|
8
8
|
import { compareVersions } from "../versions/cruze_version.js";
|
|
9
9
|
import { FIRST_RELEASE } from "../versions/rule_versions.js";
|
|
10
|
-
import { isVerified } from "./verification.js";
|
|
10
|
+
import { awaitsRethink, isVerified } from "./verification.js";
|
|
11
11
|
import { activeChange } from "./work_status.js";
|
|
12
12
|
|
|
13
13
|
/** The Cruze step to run next, computed from the project's state and the current branch. */
|
|
@@ -31,7 +31,7 @@ export function nextSteps(view , branch )
|
|
|
31
31
|
const project = projectStep(view);
|
|
32
32
|
if (project !== null) return { next: project, also: [] };
|
|
33
33
|
const active = activeChange(view, branch);
|
|
34
|
-
const inFlight = view.items.filter((item) => !item.archived && item.kind !== "change").flatMap((item) => itemSteps(view, item, branch));
|
|
34
|
+
const inFlight = inRoadmapOrder(view, view.items.filter((item) => !item.archived && item.kind !== "change")).flatMap((item) => itemSteps(view, item, branch));
|
|
35
35
|
const roadmap = roadmapStep(view);
|
|
36
36
|
const realign = realignStep(view);
|
|
37
37
|
const candidates = [
|
|
@@ -45,6 +45,28 @@ export function nextSteps(view , branch )
|
|
|
45
45
|
return { next: next ?? { step: "roadmap", reason: "every item on the roadmap has landed; close the release and plan the next" }, also };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Work in flight in the roadmap's order, with items whose roadmap blockers haven't all landed
|
|
50
|
+
* last: a feature designed early still waits for the prefactors the user put before it.
|
|
51
|
+
*/
|
|
52
|
+
function inRoadmapOrder(view , items ) {
|
|
53
|
+
const text = view.snapshot.get(PATHS.roadmap);
|
|
54
|
+
if (text === undefined) return items;
|
|
55
|
+
const rows = readRoadmapStatus(text);
|
|
56
|
+
const state = new Map(rows.map((row) => [row.slug, row.state]));
|
|
57
|
+
const slugOf = new Map(rows.map((row) => [row.id, row.slug]));
|
|
58
|
+
const roadmap = readRoadmapItems(text);
|
|
59
|
+
const rank = (item ) => {
|
|
60
|
+
const at = roadmap.findIndex((r) => r.slug === slugOf.get(item.ref));
|
|
61
|
+
const blocked = at !== -1 && (roadmap[at]?.blockedBy ?? []).some((b) => state.get(b) !== "landed");
|
|
62
|
+
return [blocked ? 1 : 0, at === -1 ? roadmap.length : at];
|
|
63
|
+
};
|
|
64
|
+
return items
|
|
65
|
+
.map((item, i) => ({ item, key: [...rank(item), i] }))
|
|
66
|
+
.sort((a, b) => (a.key[0] ?? 0) - (b.key[0] ?? 0) || (a.key[1] ?? 0) - (b.key[1] ?? 0) || (a.key[2] ?? 0) - (b.key[2] ?? 0))
|
|
67
|
+
.map(({ item }) => item);
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
/** Realign comes when nothing else is due: the installed skills expect more than the code meets. */
|
|
49
71
|
function realignStep(view ) {
|
|
50
72
|
const config = view.config?.config;
|
|
@@ -117,6 +139,7 @@ function changeStep(view , change , branch )
|
|
|
117
139
|
if (!isPlanned(change.doc)) return { step: "plan", target: change.ref, reason: "the change is designed but has no test plan or tasks yet" };
|
|
118
140
|
if (state.state === "unapproved") return { step: "plan", target: change.ref, reason: "review the plan and approve it on the branch that will build it" };
|
|
119
141
|
if (state.state !== "approved") return { step: "rethink", target: change.ref, reason: `the change or something it cites changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` };
|
|
142
|
+
if (awaitsRethink(view.journal, change, feature)) return { step: "rethink", target: feature?.ref ?? change.ref, reason: `verify sent ${change.ref} back for a design change` };
|
|
120
143
|
if (bound !== undefined && bound !== branch) return { step: "switch-branch", target: bound, reason: `${change.ref} is built on branch ${bound}` };
|
|
121
144
|
const progress = readProgress(change.doc);
|
|
122
145
|
if ([...progress.tasks.values()].some((commit) => commit === null)) return { step: "build", target: change.ref, reason: "the change has open tasks" };
|
|
@@ -15,3 +15,15 @@ export function isVerified(journal , change ) {
|
|
|
15
15
|
const verifiedHash = approvals.filter((e) => e.at < verification.at).at(-1)?.["hash"];
|
|
16
16
|
return approvals.filter((e) => e.at > verification.at).every((e) => e["hash"] === verifiedHash);
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether verify sent the change back for a rethink that hasn't been recorded yet. Tasks the
|
|
21
|
+
* agent reopens at verify would otherwise make the change look ready to build again.
|
|
22
|
+
*/
|
|
23
|
+
export function awaitsRethink(journal , change , feature ) {
|
|
24
|
+
const verification = journal.filter((e) => e.event === "verification" && e["item"] === change.ref).sort((a, b) => a.at.localeCompare(b.at)).at(-1);
|
|
25
|
+
if (verification?.["result"] !== "sent-back" || verification["back_to"] !== "rethink") return false;
|
|
26
|
+
// An architecture-level rethink records no item, and counts too.
|
|
27
|
+
const refs = new Set([change.ref, feature?.ref, undefined]);
|
|
28
|
+
return !journal.some((e) => e.event === "rethink" && e.at > verification.at && refs.has(e["item"] ));
|
|
29
|
+
}
|
|
@@ -46,7 +46,7 @@ export function changePlanProblems(
|
|
|
46
46
|
if (kindOf(id) === "MOD") {
|
|
47
47
|
const modulePaths = modulePathsOf(view, delta.doc, delta.delta, id);
|
|
48
48
|
if (!taskPaths.some((p) => modulePaths.some((m) => p.startsWith(m)))) {
|
|
49
|
-
problems.push(error(path, scope.line, "unproved-scope", `${id} is in scope but no task writes inside ${modulePaths.join(" or ") || "its path"}`));
|
|
49
|
+
problems.push(error(path, scope.line, "unproved-scope", `${id} is in scope but no task writes inside ${modulePaths.join(" or ") || "its path"}; a module that owns other files, such as a build script, lists each under its Path`));
|
|
50
50
|
}
|
|
51
51
|
} else if (kindOf(id) === "REQ") {
|
|
52
52
|
const scenarios = delta.delta.entries.find((e) => e.element.id === id)?.scenarios.map((s) => s.id) ?? [];
|
package/dist/main.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Composition root: the only place that constructs adapters.
|
|
3
|
-
import { basename } from "node:path";
|
|
3
|
+
import { basename, relative } from "node:path";
|
|
4
4
|
import { runCli } from "./adapters/inbound/cli/run_cli.js";
|
|
5
5
|
import { GitRepository } from "./adapters/outbound/git_repository.js";
|
|
6
6
|
import { NodeProjectFiles } from "./adapters/outbound/node_project_files.js";
|
|
7
7
|
import { PackageBundle } from "./adapters/outbound/package_bundle.js";
|
|
8
|
+
import { findProjectRoot } from "./adapters/outbound/project_root.js";
|
|
8
9
|
import { ProcessAgentRunner } from "./adapters/outbound/process_agent_runner.js";
|
|
9
10
|
import { SystemClock } from "./adapters/outbound/system_clock.js";
|
|
10
11
|
import { DefaultAnswerPrompter, TerminalPrompter } from "./adapters/outbound/terminal_prompter.js";
|
|
@@ -16,18 +17,21 @@ process.stdout.on("error", (error ) => {
|
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
const cwd = process.cwd();
|
|
20
|
+
// Every command but init works on the project that holds the working directory, so it runs from any subdirectory.
|
|
21
|
+
const root = process.argv[2] === "init" ? cwd : (findProjectRoot(cwd) ?? cwd);
|
|
19
22
|
const exitCode = await runCli(
|
|
20
23
|
process.argv.slice(2),
|
|
21
24
|
{
|
|
22
|
-
files: new NodeProjectFiles(
|
|
25
|
+
files: new NodeProjectFiles(root),
|
|
23
26
|
bundle: await PackageBundle.locate(),
|
|
24
27
|
clock: new SystemClock(),
|
|
25
|
-
repository: new GitRepository(
|
|
28
|
+
repository: new GitRepository(root),
|
|
26
29
|
interactivePrompter: new TerminalPrompter(),
|
|
27
30
|
unattendedPrompter: new DefaultAnswerPrompter(),
|
|
28
|
-
runner: new ProcessAgentRunner(
|
|
31
|
+
runner: new ProcessAgentRunner(root),
|
|
29
32
|
interactive: process.stdin.isTTY === true,
|
|
30
33
|
directoryName: basename(cwd),
|
|
34
|
+
workingDirectory: relative(root, cwd).split("\\").join("/"),
|
|
31
35
|
},
|
|
32
36
|
{
|
|
33
37
|
stdout: (text) => process.stdout.write(`${text}\n`),
|
package/package.json
CHANGED
|
@@ -32,7 +32,8 @@ Architect decides the how and the how big. It designs the domain, the ports and
|
|
|
32
32
|
|
|
33
33
|
- **Design for the roadmap, build for the phase.** Read the whole feature map and roadmap. When a later feature will need a seam, name it now in a `Future` fact, such as "transport will gain MQTT in phase 3, so inbound adapters stay behind `CommandSource`". Build only what the current work needs.
|
|
34
34
|
- **Adopt before you build.** Before designing any component that isn't domain logic, run the researcher role from `.agents/skills/cruze-roles/SKILL.md` on it. Record every decision in `## Adopt or build`, or at project scope in the adapter's `Adopts` fact. Every new dependency goes to the user through the dependency-approval skill.
|
|
35
|
-
- **Every element with code has a home.** Each `ENT`, `UC`, `PORT`, `ADP` and `FLOW` names its `Module` and `File`. Each file has one responsibility
|
|
35
|
+
- **Every element with code has a home.** Each `ENT`, `UC`, `PORT`, `ADP` and `FLOW` names its `Module` and `File`. Each file has one responsibility. Never split an owner across files to keep them short.
|
|
36
|
+
- **Comply with the references.** When the project must follow an external document, such as a protocol specification, it is listed under `references:` in `.cruze/config.yaml`; at project scope, ask the user whether there is one, and list it with what it governs. Read the parts each element relies on, and cite the section in the element's body, such as `RFC 4254 §5.2`, so plan, build and the reviewers can check it.
|
|
36
37
|
- **Ask, challenge and defer** as the grilling skill describes. Contexts, ports, public contracts, dependency direction and dependencies are always the user's decisions.
|
|
37
38
|
- **Stepping back is normal.** When the design shows that the vision or an earlier decision is wrong, stop and say so. The vision changes through `envision`, and an approved architecture through `rethink`.
|
|
38
39
|
|
|
@@ -42,4 +43,6 @@ Architect decides the how and the how big. It designs the domain, the ports and
|
|
|
42
43
|
- Feature scope: `plan` for the feature's first change.
|
|
43
44
|
- Tweak scope: `plan` for the standalone change.
|
|
44
45
|
|
|
46
|
+
Plan branches from main, so get the approved design onto main first, as a pull request or a merge, together with the vision or feature map it builds on. A plan branched from an unmerged design branch carries the design into the change's pull request.
|
|
47
|
+
|
|
45
48
|
Start it in a new session, so its context starts empty.
|
package/skills/build/SKILL.md
CHANGED
|
@@ -16,7 +16,7 @@ Build turns an approved plan into code, one task at a time. Each task starts wit
|
|
|
16
16
|
- `.agents/skills/cruze-behavioural-testing/SKILL.md`
|
|
17
17
|
- `.agents/skills/cruze-hexagonal-design/SKILL.md`, and its language file when one exists for the project's language
|
|
18
18
|
- `.agents/skills/cruze-dependency-approval/SKILL.md`
|
|
19
|
-
3. **Read the plan and the design.** Read the change's `change.md`. Run `cruze show <ref>` and read its output: the full body of every element the change builds, every scenario it delivers, and the settled decisions. Read an element it lists as cited only when the task calls it, from the place it names, never `feature.md` or `docs/architecture.md` whole. Read `commands:` in `.cruze/config.yaml`.
|
|
19
|
+
3. **Read the plan and the design.** Read the change's `change.md`. Run `cruze show <ref>` and read its output: the full body of every element the change builds, every scenario it delivers, and the settled decisions. Read an element it lists as cited only when the task calls it, from the place it names, never `feature.md` or `docs/architecture.md` whole. Read a reference it lists at the sections the change's elements cite. Read `commands:` in `.cruze/config.yaml`.
|
|
20
20
|
4. **Build each open task in order,** following the task loop below. Skip the tasks already ticked in `## Progress`.
|
|
21
21
|
5. **Finish.** When every task is ticked:
|
|
22
22
|
- Run the format, lint and build commands and the full suite, as `.agents/skills/cruze-formats/reference/config.md` defines it under `commands`.
|
|
@@ -53,6 +53,7 @@ The plan is design. Within a task you decide only what is local and reversible.
|
|
|
53
53
|
- a limit, timeout, buffer size, message or error case the design doesn't give. Picking one in code hides a gap in the design: the user decides it, at feature level. Two projects settled 35 of them as deviations.
|
|
54
54
|
|
|
55
55
|
Explain what you found, with the evidence, and don't work around it. Completed tasks keep their commits.
|
|
56
|
+
- **Read a deviation back before you record it.** One that says the design gives no value, text, error or outcome and names the one you chose, or that the code can't keep a promise a port or use case makes, is a rethink, not a deviation. In one change, two deviations worded that way came back as code-review blockers and sent it back at verify.
|
|
56
57
|
- Never edit `feature.md`, `docs/architecture.md` or `docs/specs/` during build. Design changes go through `rethink`.
|
|
57
58
|
|
|
58
59
|
## Protecting the check
|
|
@@ -35,7 +35,7 @@ Facts are `- Key: value` items. Keys marked required must be present; the others
|
|
|
35
35
|
| `PORT` | `Direction` (`driven` or `driving`), `Operations`, `Module`, `File` | `Implemented by`, `Future` |
|
|
36
36
|
| `ADP` | `Implements` (a driven port) or `Drives` (use cases), `Technology`, `Module`, `File` | `Adopts` (library and adopt-or-build reason), `Format` (the stored or wire format it owns, or where its schema lives) |
|
|
37
37
|
| `FLOW` | `Elements`, a Mermaid `sequenceDiagram` | `Serves` (SCN IDs, once they exist), `Failure paths` |
|
|
38
|
-
| `MOD` | `Path` (a directory ending in `/`, or one file; list several in backticks, never a glob), `Layer` (`core`, `domain`, `application`, `adapter` or `composition`) | |
|
|
38
|
+
| `MOD` | `Path` (a directory ending in `/`, or one file; list several in backticks, never a glob, including every file the module owns outside its directory, such as a build script), `Layer` (`core`, `domain`, `application`, `adapter` or `composition`) | |
|
|
39
39
|
| `RULE` | one or two sentences of rule | `Enforced by` (config layer names) |
|
|
40
40
|
| `XC` | prose | |
|
|
41
41
|
|