@printpractical/cruze 0.0.3 → 0.0.5
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 +10 -10
- package/dist/adapters/inbound/cli/argument_files.js +29 -0
- package/dist/adapters/inbound/cli/cli_context.js +3 -0
- package/dist/adapters/inbound/cli/command_options.js +20 -0
- package/dist/adapters/inbound/cli/commands/documents.js +5 -1
- package/dist/adapters/inbound/cli/commands/journal.js +3 -2
- package/dist/adapters/inbound/cli/commands/progress.js +7 -3
- package/dist/adapters/inbound/cli/commands/review.js +3 -2
- package/dist/adapters/inbound/cli/commands/status.js +7 -1
- package/dist/adapters/inbound/cli/commands.js +36 -20
- package/dist/adapters/inbound/cli/parse_command.js +12 -2
- package/dist/adapters/inbound/cli/run_cli.js +9 -5
- package/dist/adapters/outbound/node_project_files.js +2 -1
- package/dist/app/use_cases/approve_artifact.js +26 -1
- package/dist/app/use_cases/journal_events.js +12 -4
- package/dist/app/use_cases/project_status.js +8 -2
- package/dist/app/use_cases/record_progress.js +17 -0
- package/dist/domain/approvals/agent_approval.js +68 -0
- package/dist/domain/config.js +30 -2
- package/dist/domain/journal.js +5 -0
- package/dist/domain/status/verification.js +3 -2
- package/dist/domain/status/work_status.js +1 -3
- 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/about/SKILL.md +1 -1
- package/skills/architect/feature.md +7 -2
- package/skills/envision/SKILL.md +1 -1
- package/skills/envision/feature.md +1 -0
- package/skills/formats/SKILL.md +1 -1
- package/skills/formats/reference/config.md +10 -0
- package/skills/formats/reference/durable-docs.md +1 -1
- package/skills/formats/reference/work-items.md +1 -1
- package/skills/land/SKILL.md +1 -0
- package/skills/plan/SKILL.md +3 -3
- package/skills/realign/notes/0.0.4.md +27 -0
- package/skills/realign/notes/0.0.5.md +38 -0
- package/skills/realign/notes.md +2 -0
- package/skills/rethink/SKILL.md +5 -1
- package/skills/rethink/levels.md +4 -4
- package/skills/roles/SKILL.md +17 -7
- package/skills/roles/code-reviewer.md +1 -1
- package/skills/roles/design-reviewer.md +2 -2
- package/skills/verify/SKILL.md +1 -1
- package/src/adapters/inbound/cli/argument_files.ts +29 -0
- package/src/adapters/inbound/cli/cli_context.ts +3 -0
- package/src/adapters/inbound/cli/command_options.ts +20 -0
- package/src/adapters/inbound/cli/commands/documents.ts +5 -1
- package/src/adapters/inbound/cli/commands/journal.ts +3 -2
- package/src/adapters/inbound/cli/commands/progress.ts +7 -3
- package/src/adapters/inbound/cli/commands/review.ts +3 -2
- package/src/adapters/inbound/cli/commands/status.ts +7 -1
- package/src/adapters/inbound/cli/commands.ts +36 -20
- package/src/adapters/inbound/cli/parse_command.ts +12 -2
- package/src/adapters/inbound/cli/run_cli.ts +9 -5
- package/src/adapters/outbound/node_project_files.ts +2 -1
- package/src/app/use_cases/approve_artifact.ts +26 -1
- package/src/app/use_cases/journal_events.ts +12 -4
- package/src/app/use_cases/project_status.ts +8 -2
- package/src/app/use_cases/record_progress.ts +17 -0
- package/src/domain/approvals/agent_approval.ts +68 -0
- package/src/domain/config.ts +30 -2
- package/src/domain/journal.ts +5 -0
- package/src/domain/status/verification.ts +3 -2
- package/src/domain/status/work_status.ts +1 -3
- 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.5 is the fifth preview. It cuts the ceremony per change: reviews bring you only the findings that are yours to decide, plans are approved once their review closes, a rethink re-stamps the work it didn't change, and changes are sized to be worth their review and verify. 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.5/printpractical-cruze-0.0.5.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.5
|
|
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.5/printpractical-cruze-0.0.5.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:
|
|
@@ -79,7 +79,7 @@ cruze init --package https://github.com/PrintPractical/cruze/releases/download/v
|
|
|
79
79
|
|
|
80
80
|
Existing files are never overwritten, so it is safe to run in an existing repository.
|
|
81
81
|
|
|
82
|
-
The CLI writes
|
|
82
|
+
The CLI writes its result to stdout: JSON when stdout isn't a terminal, and text on a terminal. Pass `--json` to get JSON on a terminal, or `--text` to get text through a pipe, as in `cruze journal list --text | grep review`. Errors and notes go to stderr.
|
|
83
83
|
|
|
84
84
|
## Upgrading
|
|
85
85
|
|
|
@@ -118,15 +118,15 @@ 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 |
|
|
126
126
|
| `cruze status --overlap` | Lists changes on other branches that touch the same elements as this branch's change |
|
|
127
127
|
| `cruze new <kind>` | Creates a project document (`vision`, `glossary`, `architecture`, `roadmap`) or work (`feature`, `change`, `adr`, `note`) from its template, with a dated ID that is never reused |
|
|
128
128
|
| `cruze task done <T#>` | Ticks a task with its commit; `cruze task reopen` unticks one a rethink changed, and `cruze task deviation` records a small departure from the plan |
|
|
129
|
-
| `cruze features <add\|drop>` | Edits the
|
|
129
|
+
| `cruze features <add\|update\|drop>` | Edits the feature map: adds or drops a future feature, or corrects a summary |
|
|
130
130
|
| `cruze roadmap prune` | Clears landed items from the roadmap's status when a release closes |
|
|
131
131
|
| `cruze trace` | Fails when a delivered scenario has no test carrying its ID; `--all` checks every built scenario |
|
|
132
132
|
| `cruze check` | Enforces the layer rules and the file budgets; `--ci` also fails on budget warnings |
|
|
@@ -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 |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { UsageError } from "./cli_context.js";
|
|
4
|
+
|
|
5
|
+
// Files a person or agent names on the command line, such as a scratch file of review blockers or
|
|
6
|
+
// the path for an exported bundle. They may be anywhere, unlike project files, and relative paths
|
|
7
|
+
// resolve against the working directory.
|
|
8
|
+
|
|
9
|
+
export async function readArgumentFile(path ) {
|
|
10
|
+
try {
|
|
11
|
+
return await readFile(resolve(path), "utf8");
|
|
12
|
+
} catch (error) {
|
|
13
|
+
throw new UsageError(`cannot read ${path}: ${reason(error)}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function writeArgumentFile(path , text ) {
|
|
18
|
+
try {
|
|
19
|
+
await mkdir(dirname(resolve(path)), { recursive: true });
|
|
20
|
+
await writeFile(resolve(path), text, "utf8");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new UsageError(`cannot write ${path}: ${reason(error)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function reason(error ) {
|
|
27
|
+
if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { UsageError } from "./cli_context.js";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
/** Options every command takes. */
|
|
5
|
+
const GLOBAL_OPTIONS = ["json", "text", "help", "version"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Refuses an option the command doesn't take, rather than ignoring it, so a caller never gets
|
|
9
|
+
* wider output than it asked for. An unknown subcommand is left to the handler to report.
|
|
10
|
+
*/
|
|
11
|
+
export function refuseUnknownOptions(name , args , command , given ) {
|
|
12
|
+
const sub = Array.isArray(command.options) ? undefined : args[0];
|
|
13
|
+
const taken = Array.isArray(command.options) ? command.options : sub === undefined ? undefined : (command.options )[sub];
|
|
14
|
+
if (taken === undefined) return;
|
|
15
|
+
const refused = given.filter((option) => !GLOBAL_OPTIONS.includes(option) && !taken.includes(option));
|
|
16
|
+
if (refused.length === 0) return;
|
|
17
|
+
const which = sub === undefined ? `cruze ${name}` : `cruze ${name} ${sub}`;
|
|
18
|
+
const takes = taken.length === 0 ? "it takes no options" : `it takes ${taken.map((o) => `--${o}`).join(", ")}`;
|
|
19
|
+
throw new UsageError(`${which} doesn't take ${refused.map((o) => `--${o}`).join(", ")}; ${takes}`);
|
|
20
|
+
}
|
|
@@ -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(", ")}.`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { exportFeedback, listEvents, recordEvent } from "../../../../app/use_cases/journal_events.js";
|
|
2
|
+
import { writeArgumentFile } from "../argument_files.js";
|
|
2
3
|
import { UsageError, requireArgs, } from "../cli_context.js";
|
|
3
4
|
|
|
4
5
|
export async function runJournal(context , args , options ) {
|
|
@@ -14,7 +15,7 @@ export async function runJournal(context , args , options
|
|
|
14
15
|
return { json: entry, human: `Recorded ${entry.event}.` };
|
|
15
16
|
}
|
|
16
17
|
if (args[0] === "list") {
|
|
17
|
-
const entries = await listEvents(context, options.event);
|
|
18
|
+
const entries = await listEvents(context, options.event, options.item);
|
|
18
19
|
return { json: entries, human: entries.map((e) => `${e.at} ${e.event} ${JSON.stringify({ ...e, at: undefined, event: undefined, by: undefined })}`).join("\n") || "No entries." };
|
|
19
20
|
}
|
|
20
21
|
throw new UsageError(`cruze journal takes add or list, not "${args[0]}"`);
|
|
@@ -24,7 +25,7 @@ export async function runFeedback(context , args , options
|
|
|
24
25
|
if (args[0] !== "export") throw new UsageError("usage: cruze feedback export [--out <file>] [--no-redact]");
|
|
25
26
|
const bundle = await exportFeedback(context, options.redact);
|
|
26
27
|
if (options.out !== undefined) {
|
|
27
|
-
await
|
|
28
|
+
await writeArgumentFile(options.out, `${JSON.stringify(bundle, null, 2)}\n`);
|
|
28
29
|
return { json: { out: options.out, entries: bundle.entries.length }, human: `Wrote ${bundle.entries.length} entries to ${options.out}.` };
|
|
29
30
|
}
|
|
30
31
|
return { json: bundle, human: `${bundle.entries.length} journal entries${bundle.redacted ? ", redacted" : ""}.` };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addFutureFeature, completeTask, dropFutureFeature, pruneRoadmap, recordDeviation, reopenTask } from "../../../../app/use_cases/record_progress.js";
|
|
1
|
+
import { addFutureFeature, completeTask, dropFutureFeature, updateFeatureSummary, pruneRoadmap, recordDeviation, reopenTask } from "../../../../app/use_cases/record_progress.js";
|
|
2
2
|
import { abandonWork } from "../../../../app/use_cases/abandon_work.js";
|
|
3
3
|
import { UsageError, requireArgs, requireOption, } from "../cli_context.js";
|
|
4
4
|
|
|
@@ -23,17 +23,21 @@ export async function runTask(context , args , options
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export async function runFeatures(context , args , options ) {
|
|
26
|
-
requireArgs(args, ["add|drop", "slug"]);
|
|
26
|
+
requireArgs(args, ["add|update|drop", "slug"]);
|
|
27
27
|
if (args[0] === "add") {
|
|
28
28
|
const goals = (options.goals ?? "").split(",").map((g) => g.trim()).filter((g) => g !== "");
|
|
29
29
|
const report = await addFutureFeature(context, args[1] ?? "", requireOption(options.summary, "--summary"), goals);
|
|
30
30
|
return { json: report, human: `Added ${report.feature} to the future list.` };
|
|
31
31
|
}
|
|
32
|
+
if (args[0] === "update") {
|
|
33
|
+
const report = await updateFeatureSummary(context, args[1] ?? "", requireOption(options.summary, "--summary"));
|
|
34
|
+
return { json: report, human: `Updated the summary of ${report.feature} on the ${report.list === "Future" ? "future" : "implemented"} list.` };
|
|
35
|
+
}
|
|
32
36
|
if (args[0] === "drop") {
|
|
33
37
|
const report = await dropFutureFeature(context, args[1] ?? "", requireOption(options.reason, "--reason"));
|
|
34
38
|
return { json: report, human: `Dropped ${report.feature} from the future list.` };
|
|
35
39
|
}
|
|
36
|
-
throw new UsageError(`cruze features takes add or drop, not "${args[0]}"`);
|
|
40
|
+
throw new UsageError(`cruze features takes add, update or drop, not "${args[0]}"`);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export async function runRoadmap(context , args ) {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { runReview } from "../../../../app/use_cases/run_review.js";
|
|
2
|
+
import { readArgumentFile } from "../argument_files.js";
|
|
2
3
|
import { UsageError, requireArgs, } from "../cli_context.js";
|
|
3
4
|
|
|
4
5
|
export async function runReviewCommand(context , args , options ) {
|
|
5
6
|
requireArgs(args, ["role"]);
|
|
6
7
|
const round = options.round === undefined ? undefined : Number(options.round);
|
|
7
8
|
if (round !== undefined && round !== 1 && round !== 2) throw new UsageError("--round is 1 or 2");
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
if (options.blockers !== undefined && round !== 2) throw new UsageError("--blockers goes with --round 2");
|
|
10
|
+
const blockers = options.blockers === undefined ? undefined : await readArgumentFile(options.blockers);
|
|
10
11
|
const report = await runReview(context, {
|
|
11
12
|
role: args[0] ?? "",
|
|
12
13
|
...(options.item === undefined ? {} : { item: options.item }),
|
|
@@ -3,6 +3,9 @@ import { checkBuildGate, findOverlaps, showStatus, suggestNext } from "../../../
|
|
|
3
3
|
import { UsageError, } from "../cli_context.js";
|
|
4
4
|
|
|
5
5
|
export async function runStatus(context , _args , options ) {
|
|
6
|
+
if (options.override !== undefined && options.gate === undefined) throw new UsageError("--override goes with --gate build");
|
|
7
|
+
if (options.change !== undefined && !options.overlap) throw new UsageError("--change goes with --overlap");
|
|
8
|
+
if (options.gate !== undefined && options.overlap) throw new UsageError("--gate and --overlap are alternatives");
|
|
6
9
|
if (options.gate !== undefined) {
|
|
7
10
|
if (options.gate !== "build") throw new UsageError(`unknown gate "${options.gate}"; the only gate is build`);
|
|
8
11
|
const report = await checkBuildGate(context, options.override);
|
|
@@ -32,8 +35,11 @@ export async function runStatus(context , _args , options
|
|
|
32
35
|
return { json: report, human: lines.join("\n") };
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
const BASIS = { "plan-review": " (by the agent, after its plan review)", rethink: " (re-stamped by the agent after a rethink)" };
|
|
39
|
+
|
|
35
40
|
function describe(label , status ) {
|
|
36
|
-
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;
|
|
37
43
|
return `${label}: ${status.state}${detail}`;
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -9,25 +9,40 @@ import { runAbandon, runFeatures, runRoadmap, runTask } from "./commands/progres
|
|
|
9
9
|
import { runReviewCommand } from "./commands/review.js";
|
|
10
10
|
import { runNext, runStatus } from "./commands/status.js";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
12
|
+
/** A command, and the options it takes: one list, or one per subcommand for a command that has them. */
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export const COMMANDS = {
|
|
20
|
+
init: { handler: runInit, options: ["name", "yes", "agent", "package"], usage: "init [--name <name>] [--yes] [--agent claude|other] [--package <npm spec>] set up this repository and install the skills" },
|
|
21
|
+
install: { handler: runInstall, options: ["agent"], usage: "install [--agent <agent>] install or update the Cruze skills" },
|
|
22
|
+
validate: { handler: runValidate, options: [], usage: "validate check every document against the formats" },
|
|
23
|
+
status: { handler: runStatus, options: ["gate", "override", "overlap", "change"], usage: "status [--gate build [--override <reason>]] [--overlap [--change <ref>]]" },
|
|
24
|
+
next: { handler: runNext, options: [], usage: "next the step to run next on this branch, and why" },
|
|
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
|
+
},
|
|
30
|
+
new: {
|
|
31
|
+
handler: runNew,
|
|
32
|
+
options: { vision: [], glossary: [], architecture: [], roadmap: [], feature: ["title", "roadmap"], change: ["title", "feature", "roadmap"], adr: ["title"], note: ["title"] },
|
|
33
|
+
usage: "new <vision|glossary|architecture|roadmap> | new <feature|change|adr|note> <slug> --title <title> [--feature <ref>] [--roadmap <slug>]",
|
|
34
|
+
},
|
|
35
|
+
task: { handler: runTask, options: { done: ["change", "commit"], deviation: ["change"], reopen: ["change"] }, usage: "task <done|deviation|reopen> <T#> [text] [--change <ref>] [--commit <sha>]" },
|
|
36
|
+
features: { handler: runFeatures, options: { add: ["summary", "goals"], update: ["summary"], drop: ["reason"] }, usage: "features <add|update|drop> <slug> [--summary <text>] [--goals <ids>] [--reason <text>]" },
|
|
37
|
+
roadmap: { handler: runRoadmap, options: { prune: [] }, usage: "roadmap prune clear landed items from the roadmap status when a release closes" },
|
|
38
|
+
journal: { handler: runJournal, options: { add: ["set", "item"], list: ["event", "item"] }, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>] [--item <ref>]>" },
|
|
39
|
+
review: { handler: runReviewCommand, options: ["item", "round", "blockers", "base"], usage: "review <role> [--item <ref>] [--round 1|2 --blockers <file>] [--base <commit>] run a role in a fresh agent context" },
|
|
40
|
+
feedback: { handler: runFeedback, options: { export: ["out", "no-redact"] }, usage: "feedback export [--out <file>] [--no-redact]" },
|
|
41
|
+
trace: { handler: runTrace, options: ["all", "change"], usage: "trace [--all] [--change <ref>] link scenarios to the tests that prove them" },
|
|
42
|
+
check: { handler: runCheck, options: ["file", "ci"], usage: "check [<file>...] [--ci] enforce layer rules and file budgets" },
|
|
43
|
+
abandon: { handler: runAbandon, options: ["reason"], usage: "abandon <ref> --reason <text> archive a feature or standalone change that stops for good" },
|
|
44
|
+
land: { handler: runLand, options: ["override"], usage: "land [<ref>] [--override <reason>] merge a verified change into the living docs" },
|
|
45
|
+
realign: { handler: runRealign, options: { status: ["full"], done: ["change"] }, usage: "realign <status [--full]|done [--change <ref>]> what the installed Cruze expects of existing code, and recording that the code meets it" },
|
|
31
46
|
};
|
|
32
47
|
|
|
33
48
|
export const USAGE = `Usage: cruze <command> [options]
|
|
@@ -35,4 +50,5 @@ export const USAGE = `Usage: cruze <command> [options]
|
|
|
35
50
|
Commands:
|
|
36
51
|
${Object.values(COMMANDS).map((c) => ` ${c.usage}`).join("\n")}
|
|
37
52
|
|
|
38
|
-
Every command prints
|
|
53
|
+
Every command prints its result on stdout: JSON when stdout isn't a terminal or with --json,
|
|
54
|
+
and text on a terminal or with --text. Errors and notes go to stderr.`;
|
|
@@ -5,6 +5,8 @@ import { UsageError, } from "./cli_context.js";
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
|
|
@@ -15,11 +17,13 @@ export function parseCommand(argv ) {
|
|
|
15
17
|
parsed = parseArgs({
|
|
16
18
|
args: argv,
|
|
17
19
|
allowPositionals: true,
|
|
20
|
+
tokens: true,
|
|
18
21
|
options: {
|
|
19
22
|
name: { type: "string" },
|
|
20
23
|
yes: { type: "boolean", short: "y", default: false },
|
|
21
24
|
agent: { type: "string", multiple: true, default: [] },
|
|
22
25
|
json: { type: "boolean", default: false },
|
|
26
|
+
text: { type: "boolean", default: false },
|
|
23
27
|
version: { type: "boolean", short: "v", default: false },
|
|
24
28
|
help: { type: "boolean", short: "h", default: false },
|
|
25
29
|
title: { type: "string" },
|
|
@@ -48,18 +52,21 @@ export function parseCommand(argv ) {
|
|
|
48
52
|
rebase: { type: "string", multiple: true, default: [] },
|
|
49
53
|
base: { type: "string" },
|
|
50
54
|
blockers: { type: "string" },
|
|
55
|
+
"by-agent": { type: "boolean", default: false },
|
|
56
|
+
"restamp-unchanged": { type: "boolean", default: false },
|
|
51
57
|
},
|
|
52
58
|
});
|
|
53
59
|
} catch (error) {
|
|
54
60
|
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
55
61
|
}
|
|
56
|
-
const { values, positionals } = parsed;
|
|
62
|
+
const { values, positionals, tokens } = parsed;
|
|
57
63
|
const optional = (key , value ) =>
|
|
58
64
|
value === undefined ? {} : ({ [key]: value } );
|
|
59
65
|
const options = {
|
|
60
66
|
yes: values.yes,
|
|
61
67
|
agent: values.agent,
|
|
62
68
|
json: values.json,
|
|
69
|
+
text: values.text,
|
|
63
70
|
set: values.set,
|
|
64
71
|
redact: !values["no-redact"],
|
|
65
72
|
all: values.all,
|
|
@@ -69,6 +76,8 @@ export function parseCommand(argv ) {
|
|
|
69
76
|
full: values.full,
|
|
70
77
|
replan: values.replan,
|
|
71
78
|
rebase: values.rebase,
|
|
79
|
+
byAgent: values["by-agent"],
|
|
80
|
+
restampUnchanged: values["restamp-unchanged"],
|
|
72
81
|
...optional("name", values.name),
|
|
73
82
|
...optional("title", values.title),
|
|
74
83
|
...optional("feature", values.feature),
|
|
@@ -88,5 +97,6 @@ export function parseCommand(argv ) {
|
|
|
88
97
|
...optional("base", values.base),
|
|
89
98
|
...optional("blockers", values.blockers),
|
|
90
99
|
};
|
|
91
|
-
|
|
100
|
+
const given = [...new Set(tokens.flatMap((token) => (token.kind === "option" ? [token.name] : [])))];
|
|
101
|
+
return { words: positionals, options, given, help: values.help, version: values.version };
|
|
92
102
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { versionNotice } from "../../../app/use_cases/record_version.js";
|
|
2
2
|
import { CruzeError } from "../../../domain/cruze_error.js";
|
|
3
3
|
import { UsageError } from "./cli_context.js";
|
|
4
|
+
import { refuseUnknownOptions } from "./command_options.js";
|
|
4
5
|
import { COMMANDS, USAGE } from "./commands.js";
|
|
5
6
|
import { parseCommand } from "./parse_command.js";
|
|
6
7
|
|
|
@@ -16,15 +17,17 @@ const NO_VERSION_NOTICE = new Set(["init", "install", "validate"]);
|
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Runs one CLI invocation and returns the exit code: 0 on success, 1 when a check fails or
|
|
19
|
-
* an expected error occurs, 2 for usage errors.
|
|
20
|
-
*
|
|
20
|
+
* an expected error occurs, 2 for usage errors. The result goes to stdout in one format: JSON for an agent,
|
|
21
|
+
* when stdout isn't a terminal or with --json, and text for a person, on a terminal or with --text.
|
|
22
|
+
* stderr carries errors, and a note when the project's skills don't match this CLI, even when the command fails.
|
|
21
23
|
*/
|
|
22
24
|
export async function runCli(argv , context , terminal ) {
|
|
23
|
-
|
|
25
|
+
// Read from argv too, so a usage error is reported in the format asked for.
|
|
26
|
+
const json = argv.includes("--json") || (!terminal.stdoutIsTerminal && !argv.includes("--text"));
|
|
24
27
|
let notice ;
|
|
25
28
|
try {
|
|
26
29
|
const parsed = parseCommand(argv);
|
|
27
|
-
json
|
|
30
|
+
if (parsed.options.json && parsed.options.text) throw new UsageError("--json and --text are alternatives");
|
|
28
31
|
if (parsed.version) {
|
|
29
32
|
terminal.stdout(context.bundle.version);
|
|
30
33
|
return 0;
|
|
@@ -36,10 +39,11 @@ export async function runCli(argv , context , terminal
|
|
|
36
39
|
}
|
|
37
40
|
const command = COMMANDS[name];
|
|
38
41
|
if (command === undefined) throw new UsageError(`Unknown command: ${name}`);
|
|
42
|
+
refuseUnknownOptions(name, args, command, parsed.given);
|
|
39
43
|
if (!NO_VERSION_NOTICE.has(name)) notice = await versionNotice(context.files, context.bundle.version);
|
|
40
44
|
const result = await command.handler(context, args, parsed.options);
|
|
41
|
-
terminal.stderr(result.human);
|
|
42
45
|
if (json) terminal.stdout(JSON.stringify(result.json, null, 2));
|
|
46
|
+
else if (result.human !== "") terminal.stdout(result.human);
|
|
43
47
|
return result.failed === true ? 1 : 0;
|
|
44
48
|
} catch (error) {
|
|
45
49
|
if (error instanceof UsageError) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFile, lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
4
5
|
|
|
5
6
|
/** Project files on the local disk, confined to one root directory. */
|
|
6
7
|
export class NodeProjectFiles {
|
|
@@ -92,7 +93,7 @@ export class NodeProjectFiles {
|
|
|
92
93
|
assertInsideRoot(absolute ) {
|
|
93
94
|
const fromRoot = relative(this.root, absolute);
|
|
94
95
|
if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
|
|
95
|
-
throw new
|
|
96
|
+
throw new CruzeError("outside-project", `${absolute} is outside the project at ${this.root}`);
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
}
|
|
@@ -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);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
-
import { recordedEventProblems, recordedFields, } from "../../domain/journal.js";
|
|
2
|
+
import { journalPath, parseJournal, recordedEventProblems, recordedFields, } from "../../domain/journal.js";
|
|
3
3
|
import { resolveArtifact } from "../../domain/project/artifact_ref.js";
|
|
4
4
|
import { appendJournal, loadView, } from "../project_context.js";
|
|
5
5
|
|
|
@@ -18,9 +18,17 @@ export async function recordEvent(deps , event , fields
|
|
|
18
18
|
return appendJournal(deps, folder, event, { ...recorded, cruze: deps.bundle.version });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/** The journal, or one feature's or change's part of it: its own journal, and a feature's changes' too. */
|
|
22
|
+
export async function listEvents(deps , event , item ) {
|
|
23
|
+
const view = await loadView(deps.files);
|
|
24
|
+
let entries = view.journal;
|
|
25
|
+
if (item !== undefined) {
|
|
26
|
+
const resolved = resolveArtifact(view, item).item;
|
|
27
|
+
if (resolved === undefined) throw new CruzeError("not-found", `"${item}" is not a feature or change`);
|
|
28
|
+
const parts = [resolved, ...view.items.filter((i) => resolved.kind === "feature" && i.featureRef === resolved.ref)];
|
|
29
|
+
entries = parts.flatMap((part) => parseJournal(view.snapshot.get(journalPath(part.folder))).entries).sort((a, b) => a.at.localeCompare(b.at));
|
|
30
|
+
}
|
|
31
|
+
return event === undefined ? entries : entries.filter((entry) => entry.event === event);
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
2
|
import { readProgress } from "../../domain/project/progress.js";
|
|
3
3
|
import { buildProjectView } from "../../domain/project/project_view.js";
|
|
4
|
+
import { findWorkItem, } from "../../domain/project/work_items.js";
|
|
4
5
|
import { nextSteps, } from "../../domain/status/next_step.js";
|
|
5
6
|
import { activeChange, buildGateReasons, projectStatus, touchedIds, } from "../../domain/status/work_status.js";
|
|
6
7
|
import { appendJournal, loadView, } from "../project_context.js";
|
|
@@ -59,10 +60,11 @@ export async function findOverlaps(deps , ref )
|
|
|
59
60
|
const overlaps = [];
|
|
60
61
|
for (const other of await deps.repository.otherBranches()) {
|
|
61
62
|
const theirs = buildProjectView(new Map([...view.snapshot].filter(([p]) => p.startsWith("docs/")).concat([...(await deps.repository.filesOnBranch(other, ".cruze"))])));
|
|
62
|
-
// Work in flight on a branch is the change bound to it.
|
|
63
|
+
// Work in flight on a branch is the change bound to it that hasn't landed, there or here.
|
|
63
64
|
const boundThere = theirs.items.filter((i) => {
|
|
64
65
|
const bound = readProgress(i.doc).branch;
|
|
65
|
-
|
|
66
|
+
const here = findWorkItem(view.items, i.ref);
|
|
67
|
+
return !landed(i) && (here === undefined || !landed(here)) && i.ref !== change.ref && bound !== undefined && (other === bound || other.endsWith(`/${bound}`));
|
|
66
68
|
});
|
|
67
69
|
for (const item of boundThere) {
|
|
68
70
|
const shared = [...touchedIds(theirs, item)].filter((id) => mine.has(id));
|
|
@@ -71,3 +73,7 @@ export async function findOverlaps(deps , ref )
|
|
|
71
73
|
}
|
|
72
74
|
return { change: change.ref, overlaps };
|
|
73
75
|
}
|
|
76
|
+
|
|
77
|
+
function landed(item ) {
|
|
78
|
+
return item.archived || readProgress(item.doc).landed !== undefined;
|
|
79
|
+
}
|