@printpractical/cruze 0.0.4 → 0.0.6
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 +8 -8
- package/dist/adapters/inbound/cli/cli_context.js +2 -0
- package/dist/adapters/inbound/cli/commands/documents.js +5 -1
- package/dist/adapters/inbound/cli/commands/status.js +4 -1
- package/dist/adapters/inbound/cli/commands.js +5 -1
- package/dist/adapters/inbound/cli/parse_command.js +4 -0
- package/dist/app/use_cases/approve_artifact.js +26 -1
- package/dist/app/use_cases/new_work_item.js +1 -1
- package/dist/domain/approvals/agent_approval.js +68 -0
- package/dist/domain/config.js +30 -2
- package/dist/domain/journal.js +11 -1
- package/dist/domain/status/next_step.js +2 -2
- package/dist/domain/status/verification.js +3 -2
- package/dist/domain/validation/plan_rules.js +2 -0
- package/dist/domain/validation/size_rules.js +36 -0
- package/dist/domain/validation/work_item_rules.js +2 -0
- package/dist/domain/versions/rule_versions.js +3 -0
- package/package.json +1 -1
- package/skills/architect/SKILL.md +2 -2
- package/skills/architect/feature.md +9 -2
- package/skills/architect/walkthrough.md +22 -19
- package/skills/build/SKILL.md +6 -5
- package/skills/formats/SKILL.md +1 -1
- package/skills/formats/reference/config.md +11 -1
- package/skills/formats/reference/work-items.md +1 -1
- package/skills/formats/templates/change.md +1 -1
- package/skills/formats/templates/feature.md +1 -1
- package/skills/formats/templates/standalone-change.md +1 -1
- package/skills/land/SKILL.md +1 -1
- package/skills/plan/SKILL.md +4 -4
- package/skills/plan/tasks.md +2 -1
- package/skills/realign/notes/0.0.5.md +38 -0
- package/skills/realign/notes/0.0.6.md +44 -0
- package/skills/realign/notes.md +2 -0
- package/skills/research/SKILL.md +7 -1
- package/skills/rethink/SKILL.md +5 -1
- package/skills/rethink/levels.md +4 -4
- package/skills/roles/SKILL.md +23 -11
- package/skills/roles/code-reviewer.md +1 -1
- package/skills/roles/design-reviewer.md +6 -3
- package/skills/roles/verifier.md +3 -2
- package/skills/verify/SKILL.md +10 -6
- package/src/adapters/inbound/cli/cli_context.ts +2 -0
- package/src/adapters/inbound/cli/commands/documents.ts +5 -1
- package/src/adapters/inbound/cli/commands/status.ts +4 -1
- package/src/adapters/inbound/cli/commands.ts +5 -1
- package/src/adapters/inbound/cli/parse_command.ts +4 -0
- package/src/app/use_cases/approve_artifact.ts +26 -1
- package/src/app/use_cases/new_work_item.ts +1 -1
- package/src/domain/approvals/agent_approval.ts +68 -0
- package/src/domain/config.ts +30 -2
- package/src/domain/journal.ts +11 -1
- package/src/domain/status/next_step.ts +2 -2
- package/src/domain/status/verification.ts +3 -2
- package/src/domain/validation/plan_rules.ts +2 -0
- package/src/domain/validation/size_rules.ts +36 -0
- package/src/domain/validation/work_item_rules.ts +2 -0
- package/src/domain/versions/rule_versions.ts +3 -0
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.6 is the sixth preview. It cuts the waiting per change: the full test suite runs once per commit, with an optional fast tier for the task loop, the design walkthrough is one overview followed by the documents to read in full, the agent checks a review's round 2 when the fixes follow the proposals, and research checks every claim the design relies on. 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
|
|
|
@@ -22,7 +22,7 @@ Spec-driven workflows tend to fail in the same few ways:
|
|
|
22
22
|
- Everything is "fully specified", yet the result fails when a person actually uses it.
|
|
23
23
|
- Review loops keep finding new things to change and never converge.
|
|
24
24
|
|
|
25
|
-
Cruze answers each of these with a specific mechanism. A living `docs/architecture.md` is read at every step and updated by every change. Approvals are bound to document content, so stepping back is just editing. Every task names the file it touches, and `cruze check` enforces layer rules. Scenarios are traced to behavioural tests, and the system is actually run. Reviews use a closed rubric with a hard limit of two rounds.
|
|
25
|
+
Cruze answers each of these with a specific mechanism. A living `docs/architecture.md` is read at every step and updated by every change. Approvals are bound to document content, so stepping back is just editing. Every task names the file it touches, and `cruze check` enforces layer rules. Scenarios are traced to behavioural tests, and the system is actually run. Reviews use a closed rubric with a hard limit of two rounds, and bring you only the findings that are yours to decide: changes to behaviour or to the designed contracts.
|
|
26
26
|
|
|
27
27
|
## Quick start
|
|
28
28
|
|
|
@@ -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.6/printpractical-cruze-0.0.6.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.6
|
|
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.6/printpractical-cruze-0.0.6.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:
|
|
@@ -118,8 +118,8 @@ Skills call these at fixed points, and CI runs `check` and `trace` on every push
|
|
|
118
118
|
|
|
119
119
|
| Command | What it does |
|
|
120
120
|
| --- | --- |
|
|
121
|
-
| `cruze validate` | Checks every document against the formats: IDs, elements, deltas, scope rules, task lines, test plans, roadmap and config |
|
|
122
|
-
| `cruze approve <doc>` | Records an approval as a fingerprint: the document's design hash plus the hash of every upstream element it cites. `--replan` sets built elements a rethink changed back to planned; `--rebase` accepts living text a feature must now build on |
|
|
121
|
+
| `cruze validate` | Checks every document against the formats: IDs, elements, deltas, scope rules, task lines, test plans, roadmap and config, and warns when a change falls outside the size band in `changes:` |
|
|
122
|
+
| `cruze approve <doc>` | Records an approval as a fingerprint: the document's design hash plus the hash of every upstream element it cites. `--replan` sets built elements a rethink changed back to planned; `--rebase` accepts living text a feature must now build on. `--by-agent` approves a change for you once its plan review has closed, and `--restamp-unchanged` re-stamps work a rethink didn't change; both record that the agent gave the approval |
|
|
123
123
|
| `cruze next` | Names the step to run next on this branch, and why, from computed status |
|
|
124
124
|
| `cruze status` | Computes each document's state (approved, edited, upstream changed or unapproved) from content, never from stored state |
|
|
125
125
|
| `cruze status --gate build` | Passes only when this branch's change, its feature and the architecture are approved and current |
|
|
@@ -149,7 +149,7 @@ Stepping back is editing: change an upstream document and every approval that ci
|
|
|
149
149
|
| `cruze-envision` | The what: the project's vision, goals and feature map, or one feature's intent and requirements with scenarios |
|
|
150
150
|
| `cruze-architect` | The how: the architecture, a feature's architecture delta and its changes, or a tweak; ends with a design review and a walkthrough for your approval |
|
|
151
151
|
| `cruze-roadmap` | Orders the release into phases with blocking edges and goal coverage, and closes a release |
|
|
152
|
-
| `cruze-plan` | Turns a change into a test plan and ordered tasks with file targets, reviewed in a fresh context |
|
|
152
|
+
| `cruze-plan` | Turns a change into a test plan and ordered tasks with file targets, reviewed in a fresh context and approved once the review closes |
|
|
153
153
|
| `cruze-build` | Builds the change task by task, each test-first, checked and committed, and stops to rethink when the design is wrong |
|
|
154
154
|
| `cruze-verify` | Runs the checks, a fresh-context verifier that uses the real system, a two-lane code review and a manual test script, then asks you to accept |
|
|
155
155
|
| `cruze-land` | Merges the change into the living docs and updates the changelog, README and `AGENTS.md`; also lands bug fixes |
|
|
@@ -13,8 +13,12 @@ export async function runValidate(context ) {
|
|
|
13
13
|
|
|
14
14
|
export async function runApprove(context , args , options ) {
|
|
15
15
|
requireArgs(args, ["document"]);
|
|
16
|
-
|
|
16
|
+
if (options.byAgent && options.restampUnchanged) throw new UsageError("--by-agent and --restamp-unchanged are alternatives");
|
|
17
|
+
const agent = options.byAgent ? "plan-review" : options.restampUnchanged ? "rethink" : undefined;
|
|
18
|
+
const report = await approveArtifact(context, args[0] ?? "", { replan: options.replan, rebase: options.rebase, ...(agent === undefined ? {} : { agent }) });
|
|
17
19
|
const lines = [`Approved ${report.artifact} (${report.hash.slice(0, 19)}), pinned to ${report.upstream.length} upstream element(s).`];
|
|
20
|
+
if (report.basis === "plan-review") lines.push("Approved for the user: its plan review closed with no blocker open.");
|
|
21
|
+
if (report.restamped !== undefined) lines.push(`Re-stamped for the user: its own design is unchanged, and only ${report.restamped.join(", ")} changed.`);
|
|
18
22
|
if (report.stamped.length > 0) lines.push(`Marked ${report.stamped.length} element(s) planned.`);
|
|
19
23
|
if (report.bound !== undefined) lines.push(`Bound the change to branch ${report.bound}.`);
|
|
20
24
|
if (report.replanned.length > 0) lines.push(`Set back to planned: ${report.replanned.join(", ")}.`);
|
|
@@ -35,8 +35,11 @@ export async function runStatus(context , _args , options
|
|
|
35
35
|
return { json: report, human: lines.join("\n") };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const BASIS = { "plan-review": " (by the agent, after its plan review)", rethink: " (re-stamped by the agent after a rethink)" };
|
|
39
|
+
|
|
38
40
|
function describe(label , status ) {
|
|
39
|
-
const
|
|
41
|
+
const byAgent = status.state === "approved" ? BASIS[status.record?.basis ?? ""] ?? "" : "";
|
|
42
|
+
const detail = status.changed.length > 0 ? ` (${status.changed.join(", ")})` : status.unjournaled === true ? " (no journal entry)" : byAgent;
|
|
40
43
|
return `${label}: ${status.state}${detail}`;
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -22,7 +22,11 @@ export const COMMANDS = {
|
|
|
22
22
|
validate: { handler: runValidate, options: [], usage: "validate check every document against the formats" },
|
|
23
23
|
status: { handler: runStatus, options: ["gate", "override", "overlap", "change"], usage: "status [--gate build [--override <reason>]] [--overlap [--change <ref>]]" },
|
|
24
24
|
next: { handler: runNext, options: [], usage: "next the step to run next on this branch, and why" },
|
|
25
|
-
approve: {
|
|
25
|
+
approve: {
|
|
26
|
+
handler: runApprove,
|
|
27
|
+
options: ["replan", "rebase", "by-agent", "restamp-unchanged"],
|
|
28
|
+
usage: "approve <vision|architecture|roadmap|ref|path> [--replan <ID>...] [--rebase <ID>...] [--by-agent|--restamp-unchanged] record an approval of a document",
|
|
29
|
+
},
|
|
26
30
|
new: {
|
|
27
31
|
handler: runNew,
|
|
28
32
|
options: { vision: [], glossary: [], architecture: [], roadmap: [], feature: ["title", "roadmap"], change: ["title", "feature", "roadmap"], adr: ["title"], note: ["title"] },
|
|
@@ -52,6 +52,8 @@ export function parseCommand(argv ) {
|
|
|
52
52
|
rebase: { type: "string", multiple: true, default: [] },
|
|
53
53
|
base: { type: "string" },
|
|
54
54
|
blockers: { type: "string" },
|
|
55
|
+
"by-agent": { type: "boolean", default: false },
|
|
56
|
+
"restamp-unchanged": { type: "boolean", default: false },
|
|
55
57
|
},
|
|
56
58
|
});
|
|
57
59
|
} catch (error) {
|
|
@@ -74,6 +76,8 @@ export function parseCommand(argv ) {
|
|
|
74
76
|
full: values.full,
|
|
75
77
|
replan: values.replan,
|
|
76
78
|
rebase: values.rebase,
|
|
79
|
+
byAgent: values["by-agent"],
|
|
80
|
+
restampUnchanged: values["restamp-unchanged"],
|
|
77
81
|
...optional("name", values.name),
|
|
78
82
|
...optional("title", values.title),
|
|
79
83
|
...optional("feature", values.feature),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requirePlanReviewClosed, requireUnchangedSinceApproval, } from "../../domain/approvals/agent_approval.js";
|
|
1
2
|
import { evaluateApproval } from "../../domain/approvals/evaluate.js";
|
|
2
3
|
import { fingerprint, isApprovable } from "../../domain/approvals/fingerprint.js";
|
|
3
4
|
import { latestApproval, serializeApprovals, withApproval, } from "../../domain/approvals/records.js";
|
|
@@ -29,6 +30,10 @@ import { appendJournal, loadView, withFiles, } from "../project
|
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
32
37
|
|
|
33
38
|
|
|
34
39
|
|
|
@@ -36,6 +41,8 @@ import { appendJournal, loadView, withFiles, } from "../project
|
|
|
36
41
|
|
|
37
42
|
|
|
38
43
|
|
|
44
|
+
|
|
45
|
+
|
|
39
46
|
|
|
40
47
|
|
|
41
48
|
/**
|
|
@@ -53,6 +60,9 @@ export async function approveArtifact(deps , ref , options
|
|
|
53
60
|
if (item !== undefined && item.kind !== "feature" && !isPlanned(item.doc)) {
|
|
54
61
|
throw new CruzeError("not-planned", `${path} has no test plan or tasks yet; plan it before approving`);
|
|
55
62
|
}
|
|
63
|
+
const before = evaluateApproval(view, path);
|
|
64
|
+
if (options.agent === "plan-review") requirePlanReviewClosed(view.journal, item ?? notWork(path));
|
|
65
|
+
if (options.agent === "rethink") requireUnchangedSinceApproval(view.journal, item, path, before);
|
|
56
66
|
|
|
57
67
|
const edits = new Map ();
|
|
58
68
|
let text = view.snapshot.get(path) ?? "";
|
|
@@ -103,12 +113,23 @@ export async function approveArtifact(deps , ref , options
|
|
|
103
113
|
upstream,
|
|
104
114
|
approvedAt: deps.clock.now().toISOString(),
|
|
105
115
|
by: (await deps.repository.userName()) ?? "unknown",
|
|
116
|
+
...(options.agent === undefined ? {} : { basis: options.agent }),
|
|
106
117
|
});
|
|
107
118
|
for (const [changed, content] of edits) await deps.files.writeText(changed, content);
|
|
108
119
|
await deps.files.writeText(approvalsPath(folder), serializeApprovals(approvals));
|
|
109
|
-
|
|
120
|
+
const restamped = options.agent === "rethink" ? before.changed : undefined;
|
|
121
|
+
await appendJournal(deps, folder, "approve", {
|
|
122
|
+
artifact: path,
|
|
123
|
+
hash,
|
|
124
|
+
...(replanned.length > 0 ? { replanned } : {}),
|
|
125
|
+
...(rebased.length > 0 ? { rebased } : {}),
|
|
126
|
+
...(options.agent === undefined ? {} : { basis: options.agent }),
|
|
127
|
+
...(restamped === undefined ? {} : { restamped }),
|
|
128
|
+
});
|
|
110
129
|
const report = { artifact: path, hash, upstream: Object.keys(upstream), stamped, replanned, rebased };
|
|
111
130
|
if (bound !== undefined) report.bound = bound;
|
|
131
|
+
if (options.agent !== undefined) report.basis = options.agent;
|
|
132
|
+
if (restamped !== undefined) report.restamped = restamped;
|
|
112
133
|
return report;
|
|
113
134
|
}
|
|
114
135
|
|
|
@@ -133,6 +154,10 @@ function requireRebased(view , deltaIds , previous
|
|
|
133
154
|
}
|
|
134
155
|
}
|
|
135
156
|
|
|
157
|
+
function notWork(path ) {
|
|
158
|
+
throw new CruzeError("needs-user", `${path} is a project document, so the user approves it`);
|
|
159
|
+
}
|
|
160
|
+
|
|
136
161
|
/** A change needs its feature approved; a feature or standalone change needs the architecture approved. */
|
|
137
162
|
function requireUpstreamApproved(view , path ) {
|
|
138
163
|
const item = view.items.find((i) => i.path === path);
|
|
@@ -28,7 +28,7 @@ import { appendJournal, loadView, } from "../project_context.js
|
|
|
28
28
|
export async function createWorkItem(deps , request ) {
|
|
29
29
|
const view = await loadView(deps.files);
|
|
30
30
|
const date = deps.clock.today();
|
|
31
|
-
const values = { id: "", title: request.title, date, project_name: "" };
|
|
31
|
+
const values = { id: "", title: request.title, title_quoted: JSON.stringify(request.title), date, project_name: "" };
|
|
32
32
|
if (request.title.trim() === "") throw new CruzeError("missing-title", "a title is required");
|
|
33
33
|
|
|
34
34
|
if (request.kind === "adr") {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { CruzeError } from "../cruze_error.js";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Why an approval was given without the user: a plan whose review closed, or work that a
|
|
8
|
+
* rethink left unchanged while something it cites moved. The user can still object, and
|
|
9
|
+
* editing the document makes the approval stale as usual.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const byTime = (a , b ) => a.at.localeCompare(b.at);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A feature's change may be approved by the agent once its plan review has closed: a review
|
|
17
|
+
* recorded since the change was last approved, no blocker left open in its last round, and a
|
|
18
|
+
* disposition for every finding of its first round. The feature's design, and a standalone
|
|
19
|
+
* change's, stay the user's to approve.
|
|
20
|
+
*/
|
|
21
|
+
export function requirePlanReviewClosed(journal , item ) {
|
|
22
|
+
if (item.kind !== "change") {
|
|
23
|
+
throw new CruzeError("needs-user", `${item.ref} carries design, so the user approves it; only a feature's change can be approved after its plan review`);
|
|
24
|
+
}
|
|
25
|
+
const entries = journal.filter((e) => e["item"] === item.ref || e["artifact"] === item.path).sort(byTime);
|
|
26
|
+
const lastApproval = entries.filter((e) => e.event === "approve").at(-1);
|
|
27
|
+
const reviews = entries.filter((e) => e.event === "review" && e["review"] === "plan" && (lastApproval === undefined || e.at > lastApproval.at));
|
|
28
|
+
const last = reviews.at(-1);
|
|
29
|
+
if (last === undefined) {
|
|
30
|
+
const since = lastApproval === undefined ? "" : " since the change was last approved";
|
|
31
|
+
throw new CruzeError("plan-review-open", `no plan review is recorded for ${item.ref}${since}; run the plan review first`);
|
|
32
|
+
}
|
|
33
|
+
const blockers = Number(last["blockers"] ?? 0);
|
|
34
|
+
if (blockers > 0 && Number(last["round"]) === 1) {
|
|
35
|
+
throw new CruzeError("plan-review-open", `the plan review's round 1 found ${blockers} blocker(s); check their fixes in round 2`);
|
|
36
|
+
}
|
|
37
|
+
if (blockers > 0) {
|
|
38
|
+
throw new CruzeError("plan-review-open", `round 2 left ${blockers} blocker(s) open; the user decides them and approves the plan`);
|
|
39
|
+
}
|
|
40
|
+
const first = reviews.filter((e) => Number(e["round"]) === 1).at(-1) ?? last;
|
|
41
|
+
const findings = Number(first["blockers"] ?? 0) + Number(first["concerns"] ?? 0);
|
|
42
|
+
const decided = entries.filter((e) => e.event === "disposition" && e["review"] === "plan" && e.at > first.at).length;
|
|
43
|
+
if (decided < findings) {
|
|
44
|
+
throw new CruzeError("plan-review-open", `the plan review raised ${findings} finding(s) but ${decided} have a disposition; record one for each`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A rethink may re-approve, for the user, a feature or change it didn't edit: one whose own
|
|
50
|
+
* design is unchanged and that is stale only because something it cites changed. A change
|
|
51
|
+
* with a task reopened since its approval goes back to the user.
|
|
52
|
+
*/
|
|
53
|
+
export function requireUnchangedSinceApproval(journal , item , path , status ) {
|
|
54
|
+
if (item === undefined) {
|
|
55
|
+
throw new CruzeError("needs-user", `${path} is a project document, so the user re-approves it; a rethink re-stamps only features and changes`);
|
|
56
|
+
}
|
|
57
|
+
if (status.state === "edited") {
|
|
58
|
+
throw new CruzeError("needs-user", `${item.ref} itself changed since its approval, so the user re-approves it`);
|
|
59
|
+
}
|
|
60
|
+
if (status.state !== "upstream-changed") {
|
|
61
|
+
throw new CruzeError("not-stale", `${item.ref} is ${status.state}; only work whose upstream changed can be re-stamped`);
|
|
62
|
+
}
|
|
63
|
+
const approvedAt = journal.filter((e) => e.event === "approve" && e["artifact"] === path).sort(byTime).at(-1)?.at ?? "";
|
|
64
|
+
const reopened = journal.filter((e) => e.event === "reopen" && e["change"] === item.ref && e.at > approvedAt).map((e) => String(e["task"]));
|
|
65
|
+
if (reopened.length > 0) {
|
|
66
|
+
throw new CruzeError("needs-user", `${item.ref} had ${reopened.join(", ")} reopened since its approval, so the user re-approves it`);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/domain/config.js
CHANGED
|
@@ -23,9 +23,25 @@ import { parse as parseYaml } from "yaml";
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/** Which review findings the user decides: only the exceptions, or every one. */
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
27
41
|
|
|
28
42
|
|
|
43
|
+
export const DEFAULT_CHANGE_LIMITS = { minBuilds: 3, maxScenarios: 30, maxTasks: 25 };
|
|
44
|
+
|
|
29
45
|
|
|
30
46
|
|
|
31
47
|
|
|
@@ -97,6 +113,14 @@ export function parseConfig(text ) {
|
|
|
97
113
|
const review = asRecord(root["review"]) ?? {};
|
|
98
114
|
const reviewCommand = review["command"] === undefined ? undefined : stringList(review["command"], "review.command", problems);
|
|
99
115
|
if (reviewCommand?.length === 0) problems.push("review.command needs at least the program to run");
|
|
116
|
+
const decide = review["decide"] ?? "exceptions";
|
|
117
|
+
if (decide !== "exceptions" && decide !== "all") problems.push("review.decide must be exceptions or all");
|
|
118
|
+
const limits = asRecord(root["changes"]) ?? {};
|
|
119
|
+
const changes = {
|
|
120
|
+
minBuilds: positiveInt(limits["min_builds"], "changes.min_builds", problems) ?? DEFAULT_CHANGE_LIMITS.minBuilds,
|
|
121
|
+
maxScenarios: positiveInt(limits["max_scenarios"], "changes.max_scenarios", problems) ?? DEFAULT_CHANGE_LIMITS.maxScenarios,
|
|
122
|
+
maxTasks: positiveInt(limits["max_tasks"], "changes.max_tasks", problems) ?? DEFAULT_CHANGE_LIMITS.maxTasks,
|
|
123
|
+
};
|
|
100
124
|
|
|
101
125
|
const config = {
|
|
102
126
|
version: 1,
|
|
@@ -109,7 +133,11 @@ export function parseConfig(text ) {
|
|
|
109
133
|
check: { maxLines: maxLines ?? 250, maxTypes: maxTypes ?? 5, exceptions },
|
|
110
134
|
layers,
|
|
111
135
|
commands,
|
|
112
|
-
review:
|
|
136
|
+
review: {
|
|
137
|
+
...(reviewCommand === undefined || reviewCommand.length === 0 ? {} : { command: reviewCommand }),
|
|
138
|
+
decide: decide === "all" ? "all" : "exceptions",
|
|
139
|
+
},
|
|
140
|
+
changes,
|
|
113
141
|
};
|
|
114
142
|
return { config, problems };
|
|
115
143
|
}
|
package/dist/domain/journal.js
CHANGED
|
@@ -15,12 +15,14 @@ export const RECORDED_EVENTS = {
|
|
|
15
15
|
override: ["gate", "reason"],
|
|
16
16
|
bug: ["summary", "cause"],
|
|
17
17
|
verification: ["result", "summary"],
|
|
18
|
+
checks: ["commit"],
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
/** Fields an event may carry beyond its required ones, checked when present. A defect rethink must say which step missed it. */
|
|
21
22
|
const OPTIONAL_FIELDS = {
|
|
22
23
|
rethink: ["missed_by"],
|
|
23
|
-
review: ["nits"],
|
|
24
|
+
review: ["nits", "checked_by"],
|
|
25
|
+
disposition: ["decided"],
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
/** The workflow steps and checks that find or miss a problem, so a retro can count them. */
|
|
@@ -30,8 +32,10 @@ export const FIELD_VALUES = {
|
|
|
30
32
|
level: ["task", "change", "feature", "architecture", "vision"],
|
|
31
33
|
kind: ["defect", "discovery"],
|
|
32
34
|
disposition: ["fixed", "waived", "deferred", "rejected"],
|
|
35
|
+
decided: ["agent", "user"],
|
|
33
36
|
review: ["design", "plan", "code", "realign"],
|
|
34
37
|
result: ["accepted", "sent-back"],
|
|
38
|
+
checked_by: ["agent", "reviewer"],
|
|
35
39
|
found_by: STEPS,
|
|
36
40
|
missed_by: STEPS,
|
|
37
41
|
};
|
|
@@ -77,6 +81,12 @@ export function recordedEventProblems(event , fields
|
|
|
77
81
|
if (allowed !== undefined && !allowed.includes(value)) problems.push(`${field} must be one of ${allowed.join(", ")}`);
|
|
78
82
|
if (COUNT_FIELDS.includes(field) && !/^\d+$/.test(value)) problems.push(`${field} must be a whole number`);
|
|
79
83
|
}
|
|
84
|
+
if (event === "checks" && fields["commit"] !== undefined && !/^[0-9a-f]{7,40}$/.test(fields["commit"])) {
|
|
85
|
+
problems.push("commit must be a commit hash, such as the output of git rev-parse --short HEAD");
|
|
86
|
+
}
|
|
87
|
+
if (event === "disposition" && fields["decided"] === "agent" && fields["disposition"] !== "fixed") {
|
|
88
|
+
problems.push("the agent decides only fixes; the user waives, defers or rejects a finding (decided=user)");
|
|
89
|
+
}
|
|
80
90
|
return problems;
|
|
81
91
|
}
|
|
82
92
|
|
|
@@ -66,7 +66,7 @@ function projectStep(view ) {
|
|
|
66
66
|
{
|
|
67
67
|
path: PATHS.architecture,
|
|
68
68
|
missing: { step: "architect", reason: "the project has no architecture yet" },
|
|
69
|
-
unapproved: { step: "architect", reason: "finish the architecture,
|
|
69
|
+
unapproved: { step: "architect", reason: "finish the architecture, review it and approve it" },
|
|
70
70
|
changed: { step: "rethink", target: "architecture", reason: "the architecture changed since its approval" },
|
|
71
71
|
},
|
|
72
72
|
{
|
|
@@ -89,7 +89,7 @@ function itemSteps(view , item , branch )
|
|
|
89
89
|
if (item.kind === "standalone") return [changeStep(view, item, branch)];
|
|
90
90
|
const state = evaluateApproval(view, item.path);
|
|
91
91
|
if (readChangesTable(item.doc).length === 0) return [{ step: "architect", target: item.ref, reason: "the feature has no design or changes yet" }];
|
|
92
|
-
if (state.state === "unapproved") return [{ step: "architect", target: item.ref, reason: "review the feature's design
|
|
92
|
+
if (state.state === "unapproved") return [{ step: "architect", target: item.ref, reason: "review the feature's design and approve it" }];
|
|
93
93
|
if (state.state !== "approved") return [{ step: "rethink", target: item.ref, reason: `the feature changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` }];
|
|
94
94
|
const changes = changesOf(view.items, item);
|
|
95
95
|
const landed = new Set(changes.filter((c) => readProgress(c.doc).landed !== undefined).map((c) => c.folderName));
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Whether the user accepted a change at verify. The latest verification recorded for the change
|
|
6
6
|
* counts. A later approval keeps it only when the change's own design is unchanged, as when it is
|
|
7
|
-
* re-approved because something upstream moved; a changed plan means verifying again.
|
|
7
|
+
* re-approved because something upstream moved; a changed plan means verifying again. Land's
|
|
8
|
+
* re-stamps don't count, since they follow the merge; an approval the agent gave does.
|
|
8
9
|
*/
|
|
9
10
|
export function isVerified(journal , change ) {
|
|
10
11
|
const byTime = (a , b ) => a.at.localeCompare(b.at);
|
|
11
12
|
const verification = journal.filter((e) => e.event === "verification" && e["item"] === change.ref).sort(byTime).at(-1);
|
|
12
13
|
if (verification?.["result"] !== "accepted") return false;
|
|
13
|
-
const approvals = journal.filter((e) => e.event === "approve" && e["artifact"] === change.path && e["basis"]
|
|
14
|
+
const approvals = journal.filter((e) => e.event === "approve" && e["artifact"] === change.path && !String(e["basis"] ?? "").startsWith("land ")).sort(byTime);
|
|
14
15
|
const verifiedHash = approvals.filter((e) => e.at < verification.at).at(-1)?.["hash"];
|
|
15
16
|
return approvals.filter((e) => e.at > verification.at).every((e) => e["hash"] === verifiedHash);
|
|
16
17
|
}
|
|
@@ -7,6 +7,7 @@ import { findIds, kindOf } from "../ids.js";
|
|
|
7
7
|
import { isPlanned, readScope, readTasks, readTestPlan, } from "../project/plan_parts.js";
|
|
8
8
|
|
|
9
9
|
import { error, warning, } from "./problem.js";
|
|
10
|
+
import { planSizeProblems } from "./size_rules.js";
|
|
10
11
|
|
|
11
12
|
const TEST_KINDS = ["behaviour", "contract", "domain", "smoke"];
|
|
12
13
|
|
|
@@ -31,6 +32,7 @@ export function changePlanProblems(
|
|
|
31
32
|
problems.push(error(path, line, "task-format", "task lines read: - T<n>: `<owner>` in `<path>`[, `<path>`], proves <ID>[, <ID>]"));
|
|
32
33
|
}
|
|
33
34
|
if (tasks.length === 0 && malformed.length === 0) problems.push(error(path, undefined, "missing-tasks", "the change has no tasks"));
|
|
35
|
+
problems.push(...planSizeProblems(view, path, tasks.length));
|
|
34
36
|
|
|
35
37
|
const proved = new Set(tasks.flatMap((task) => task.proves));
|
|
36
38
|
const taskPaths = tasks.flatMap((task) => task.paths);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DEFAULT_CHANGE_LIMITS, } from "../config.js";
|
|
2
|
+
import { kindOf } from "../ids.js";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import { warning, } from "./problem.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every change pays for its own plan review, code review, verifier run and land, so a change
|
|
9
|
+
* should carry enough work to be worth them, and no more than one review and one run can hold.
|
|
10
|
+
*/
|
|
11
|
+
export function changeLimits(view ) {
|
|
12
|
+
return view.config?.config?.changes ?? DEFAULT_CHANGE_LIMITS;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A feature's changes not yet landed that fall outside the size band. A feature of one change has nothing to merge into. */
|
|
16
|
+
export function changeRowSizeProblems(view , path , rows , landed ) {
|
|
17
|
+
const limits = changeLimits(view);
|
|
18
|
+
const problems = [];
|
|
19
|
+
for (const row of rows.filter((r) => !landed.has(r.change))) {
|
|
20
|
+
if (rows.length > 1 && row.builds.length < limits.minBuilds) {
|
|
21
|
+
problems.push(warning(path, row.line, "change-too-small", `${row.change} builds ${row.builds.length} element(s), under ${limits.minBuilds}; merge it into the change it continues, since each change pays for its own plan, review and verify`));
|
|
22
|
+
}
|
|
23
|
+
const scenarios = row.delivers.filter((id) => kindOf(id) === "SCN").length;
|
|
24
|
+
if (scenarios > limits.maxScenarios) {
|
|
25
|
+
problems.push(warning(path, row.line, "change-too-large", `${row.change} delivers ${scenarios} scenarios, over ${limits.maxScenarios}; split it, since one verifier run has to go through them all`));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return problems;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A plan with more tasks than one code review and one verify can hold. */
|
|
32
|
+
export function planSizeProblems(view , path , tasks ) {
|
|
33
|
+
const { maxTasks } = changeLimits(view);
|
|
34
|
+
if (tasks <= maxTasks) return [];
|
|
35
|
+
return [warning(path, undefined, "plan-too-large", `the plan has ${tasks} tasks, over ${maxTasks}; split the change through rethink at feature level`)];
|
|
36
|
+
}
|
|
@@ -11,6 +11,7 @@ import { deltaProblems } from "./delta_rules.js";
|
|
|
11
11
|
import { malformedIdHeadings, lineOf } from "./living_rules.js";
|
|
12
12
|
import { changePlanProblems } from "./plan_rules.js";
|
|
13
13
|
import { error, warning, } from "./problem.js";
|
|
14
|
+
import { changeRowSizeProblems } from "./size_rules.js";
|
|
14
15
|
|
|
15
16
|
const REQUIRED_SECTIONS = {
|
|
16
17
|
feature: ["Intent", "Spec delta", "Architecture delta", "Changes"],
|
|
@@ -71,6 +72,7 @@ function featureScopeProblems(view , feature , delta
|
|
|
71
72
|
if (extra !== undefined) {
|
|
72
73
|
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
74
|
}
|
|
75
|
+
problems.push(...changeRowSizeProblems(view, feature.path, rows, landedChanges));
|
|
74
76
|
for (const row of rows) {
|
|
75
77
|
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`));
|
|
76
78
|
if (names.indexOf(row.change) !== names.lastIndexOf(row.change)) problems.push(error(feature.path, row.line, "change-name", `change ${row.change} is listed twice`));
|
|
@@ -13,6 +13,9 @@ export const RULE_SINCE = {
|
|
|
13
13
|
"validate:outside-module-map": "0.0.2",
|
|
14
14
|
"validate:readme-overview": "0.0.2",
|
|
15
15
|
"validate:feature-size": "0.0.2",
|
|
16
|
+
"validate:change-too-small": "0.0.5",
|
|
17
|
+
"validate:change-too-large": "0.0.5",
|
|
18
|
+
"validate:plan-too-large": "0.0.5",
|
|
16
19
|
};
|
|
17
20
|
|
|
18
21
|
/** Whether a rule arrived after the version the project's code meets, so it waits for `realign`. */
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ description: Decides how to build and how big, at project scope (domain model, c
|
|
|
5
5
|
|
|
6
6
|
# Architect
|
|
7
7
|
|
|
8
|
-
Architect decides the how and the how big. It designs the domain, the ports and adapters, and where every element lives in the source tree, before any code exists. It ends with the one heavy human gate, the design walkthrough
|
|
8
|
+
Architect decides the how and the how big. It designs the domain, the ports and adapters, and where every element lives in the source tree, before any code exists. It ends with the one heavy human gate, the design walkthrough: a short overview in plain terms, after which the user reads the design documents in full and approves or comments.
|
|
9
9
|
|
|
10
10
|
## Steps
|
|
11
11
|
|
|
@@ -25,7 +25,7 @@ Architect decides the how and the how big. It designs the domain, the ports and
|
|
|
25
25
|
- `.agents/skills/cruze-formats/reference/architecture.md`
|
|
26
26
|
5. **Design.** Follow the scope's procedure: [project.md](project.md), [feature.md](feature.md) or [tweak.md](tweak.md). Tweak scope ends there and hands off to `plan`.
|
|
27
27
|
6. **Review the design.** Run the design review in `.agents/skills/cruze-roles/SKILL.md`, with the design reviewer role. Done when every finding has a journaled disposition and no blocker is open without the user's decision.
|
|
28
|
-
7. **
|
|
28
|
+
7. **Hand the design to the user.** Follow [walkthrough.md](walkthrough.md): one overview message, then the documents to read in full. Apply what the user changes, run `cruze validate` again, and say what changed.
|
|
29
29
|
8. **Record the approval.** Only after the user says they approve, run the scope's `cruze approve` command and quote its result, then commit. At project scope, the roadmap follows as its own approval, described in [project.md](project.md).
|
|
30
30
|
|
|
31
31
|
## Rules
|
|
@@ -21,11 +21,18 @@ The result is a `feature.md` with a complete architecture delta and an ordered l
|
|
|
21
21
|
|
|
22
22
|
## Split it into changes
|
|
23
23
|
|
|
24
|
-
9. **Slice vertically.** Each change delivers
|
|
24
|
+
9. **Slice vertically.** Each change delivers scenarios end to end, through every layer they need, and can land on its own. Every change pays for its own plan review, code review, verifier run and land, so slice by what is worth that cost, not by the smallest piece that could land. The feature's first change proves the design end to end; later changes widen it in steps of real work.
|
|
25
25
|
10. **Fill the Changes table.** Give each row a number and a slug, such as `01-local-serial`. List the scenario and requirement IDs it `Delivers`, the architecture IDs it `Builds`, any IDs it `Removes`, and the changes it `Depends on`.
|
|
26
26
|
- A change builds an element only when its scenarios prove the whole element. Each delta element is built by exactly one change, so when an element's duties arrive in different changes, split it into elements that each arrive whole, such as frame limits split out of a concurrency rule.
|
|
27
27
|
- `Depends on` names every earlier change that builds something this change's scenarios need.
|
|
28
28
|
11. **Cover everything exactly once.** Every delta ID appears in exactly one change. A module the delta adds, or one still `planned` in the living docs, is built by the first change that writes a file in it. A module already `built` isn't listed. Done when `cruze validate` reports no errors for the feature, and no `not-designed` warning.
|
|
29
|
-
12. **Size each change, and the feature.**
|
|
29
|
+
12. **Size each change, and the feature.** Aim for changes of about 8 to 25 tasks, each one a sitting's worth of review and verify:
|
|
30
|
+
- **Merge** a change that builds fewer than 3 elements into the change it continues, when they share adapters or screens, or form a chain where each depends on the one before. Several small changes to the same screens are one change. `cruze validate` warns `change-too-small`.
|
|
31
|
+
- **Split** a change that delivers more than 30 scenarios, or would need more than 25 tasks, or touches more than one context's domain. One verifier run and one code review can't hold more. `cruze validate` warns `change-too-large`, and `plan-too-large` once a plan passes 25 tasks.
|
|
32
|
+
- A feature of more than 6 changes is more than one design review can hold: split it into features that each serve part of its goals, and run `cruze features add` for the later ones. `cruze validate` warns `feature-size` until it is split.
|
|
33
|
+
|
|
34
|
+
The thresholds are the defaults under `changes:` in `.cruze/config.yaml`; a project can tune them.
|
|
35
|
+
|
|
36
|
+
Done when `cruze validate` reports no `change-too-small`, `change-too-large` or `feature-size` warning, or the user has accepted one and a `D<n>` line under the feature's `## Decisions` records why. After approval, only a rethink can split a change.
|
|
30
37
|
|
|
31
38
|
Then continue with the review and walkthrough steps of the skill. When the user approves, run `cruze approve <feature>` and quote its result. The command takes the feature's full ID, such as `2026-09-25-open-console`, or its slug when no other active item shares it.
|