@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.
Files changed (71) hide show
  1. package/README.md +10 -10
  2. package/dist/adapters/inbound/cli/argument_files.js +29 -0
  3. package/dist/adapters/inbound/cli/cli_context.js +3 -0
  4. package/dist/adapters/inbound/cli/command_options.js +20 -0
  5. package/dist/adapters/inbound/cli/commands/documents.js +5 -1
  6. package/dist/adapters/inbound/cli/commands/journal.js +3 -2
  7. package/dist/adapters/inbound/cli/commands/progress.js +7 -3
  8. package/dist/adapters/inbound/cli/commands/review.js +3 -2
  9. package/dist/adapters/inbound/cli/commands/status.js +7 -1
  10. package/dist/adapters/inbound/cli/commands.js +36 -20
  11. package/dist/adapters/inbound/cli/parse_command.js +12 -2
  12. package/dist/adapters/inbound/cli/run_cli.js +9 -5
  13. package/dist/adapters/outbound/node_project_files.js +2 -1
  14. package/dist/app/use_cases/approve_artifact.js +26 -1
  15. package/dist/app/use_cases/journal_events.js +12 -4
  16. package/dist/app/use_cases/project_status.js +8 -2
  17. package/dist/app/use_cases/record_progress.js +17 -0
  18. package/dist/domain/approvals/agent_approval.js +68 -0
  19. package/dist/domain/config.js +30 -2
  20. package/dist/domain/journal.js +5 -0
  21. package/dist/domain/status/verification.js +3 -2
  22. package/dist/domain/status/work_status.js +1 -3
  23. package/dist/domain/validation/plan_rules.js +2 -0
  24. package/dist/domain/validation/size_rules.js +36 -0
  25. package/dist/domain/validation/work_item_rules.js +2 -0
  26. package/dist/domain/versions/rule_versions.js +3 -0
  27. package/package.json +1 -1
  28. package/skills/about/SKILL.md +1 -1
  29. package/skills/architect/feature.md +7 -2
  30. package/skills/envision/SKILL.md +1 -1
  31. package/skills/envision/feature.md +1 -0
  32. package/skills/formats/SKILL.md +1 -1
  33. package/skills/formats/reference/config.md +10 -0
  34. package/skills/formats/reference/durable-docs.md +1 -1
  35. package/skills/formats/reference/work-items.md +1 -1
  36. package/skills/land/SKILL.md +1 -0
  37. package/skills/plan/SKILL.md +3 -3
  38. package/skills/realign/notes/0.0.4.md +27 -0
  39. package/skills/realign/notes/0.0.5.md +38 -0
  40. package/skills/realign/notes.md +2 -0
  41. package/skills/rethink/SKILL.md +5 -1
  42. package/skills/rethink/levels.md +4 -4
  43. package/skills/roles/SKILL.md +17 -7
  44. package/skills/roles/code-reviewer.md +1 -1
  45. package/skills/roles/design-reviewer.md +2 -2
  46. package/skills/verify/SKILL.md +1 -1
  47. package/src/adapters/inbound/cli/argument_files.ts +29 -0
  48. package/src/adapters/inbound/cli/cli_context.ts +3 -0
  49. package/src/adapters/inbound/cli/command_options.ts +20 -0
  50. package/src/adapters/inbound/cli/commands/documents.ts +5 -1
  51. package/src/adapters/inbound/cli/commands/journal.ts +3 -2
  52. package/src/adapters/inbound/cli/commands/progress.ts +7 -3
  53. package/src/adapters/inbound/cli/commands/review.ts +3 -2
  54. package/src/adapters/inbound/cli/commands/status.ts +7 -1
  55. package/src/adapters/inbound/cli/commands.ts +36 -20
  56. package/src/adapters/inbound/cli/parse_command.ts +12 -2
  57. package/src/adapters/inbound/cli/run_cli.ts +9 -5
  58. package/src/adapters/outbound/node_project_files.ts +2 -1
  59. package/src/app/use_cases/approve_artifact.ts +26 -1
  60. package/src/app/use_cases/journal_events.ts +12 -4
  61. package/src/app/use_cases/project_status.ts +8 -2
  62. package/src/app/use_cases/record_progress.ts +17 -0
  63. package/src/domain/approvals/agent_approval.ts +68 -0
  64. package/src/domain/config.ts +30 -2
  65. package/src/domain/journal.ts +5 -0
  66. package/src/domain/status/verification.ts +3 -2
  67. package/src/domain/status/work_status.ts +1 -3
  68. package/src/domain/validation/plan_rules.ts +2 -0
  69. package/src/domain/validation/size_rules.ts +36 -0
  70. package/src/domain/validation/work_item_rules.ts +2 -0
  71. 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.3 is the third preview. It sets projects up for agents other than Claude Code, and runs reviews in the agent's own helpers when no review agent is configured. 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.
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.3/printpractical-cruze-0.0.3.tgz
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.3
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.3/printpractical-cruze-0.0.3.tgz
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 JSON to stdout when it isn't attached to a terminal, and a human summary to stderr. Pass `--json` to get JSON on a terminal too.
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 future list of the feature map |
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
- const report = await approveArtifact(context, args[0] ?? "", { replan: options.replan, rebase: options.rebase });
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 context.files.writeText(options.out, `${JSON.stringify(bundle, null, 2)}\n`);
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
- const blockers = options.blockers === undefined ? undefined : await context.files.readText(options.blockers);
9
- if (options.blockers !== undefined && blockers === undefined) throw new UsageError(`cannot read ${options.blockers}`);
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 detail = status.changed.length > 0 ? ` (${status.changed.join(", ")})` : status.unjournaled === true ? " (no journal entry)" : "";
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
- export const COMMANDS = {
13
- init: { handler: runInit, usage: "init [--name <name>] [--yes] [--agent claude|other] [--package <npm spec>] set up this repository and install the skills" },
14
- install: { handler: runInstall, usage: "install [--agent <agent>] install or update the Cruze skills" },
15
- validate: { handler: runValidate, usage: "validate check every document against the formats" },
16
- status: { handler: runStatus, usage: "status [--gate build [--override <reason>]] [--overlap [--change <ref>]]" },
17
- next: { handler: runNext, usage: "next the step to run next on this branch, and why" },
18
- approve: { handler: runApprove, usage: "approve <vision|architecture|roadmap|ref|path> [--replan <ID>...] [--rebase <ID>...] record your approval of a document" },
19
- new: { handler: runNew, usage: "new <vision|glossary|architecture|roadmap> | new <feature|change|adr|note> <slug> --title <title> [--feature <ref>] [--roadmap <slug>]" },
20
- task: { handler: runTask, usage: "task <done|deviation|reopen> <T#> [text] [--change <ref>] [--commit <sha>]" },
21
- features: { handler: runFeatures, usage: "features <add|drop> <slug> [--summary <text> --goals <ids>] [--reason <text>]" },
22
- roadmap: { handler: runRoadmap, usage: "roadmap prune clear landed items from the roadmap status when a release closes" },
23
- journal: { handler: runJournal, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>]>" },
24
- review: { handler: runReviewCommand, usage: "review <role> [--item <ref>] [--round 1|2 --blockers <file>] [--base <commit>] run a role in a fresh agent context" },
25
- feedback: { handler: runFeedback, usage: "feedback export [--out <file>] [--no-redact]" },
26
- trace: { handler: runTrace, usage: "trace [--all] [--change <ref>] link scenarios to the tests that prove them" },
27
- check: { handler: runCheck, usage: "check [<file>...] [--ci] enforce layer rules and file budgets" },
28
- abandon: { handler: runAbandon, usage: "abandon <ref> --reason <text> archive a feature or standalone change that stops for good" },
29
- land: { handler: runLand, usage: "land [<ref>] [--override <reason>] merge a verified change into the living docs" },
30
- realign: { handler: runRealign, usage: "realign <status [--full]|done [--change <ref>]> what the installed Cruze expects of existing code, and recording that the code meets it" },
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 JSON on stdout when it isn't a terminal, or with --json.`;
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
- return { words: positionals, options, help: values.help, version: values.version };
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. Agents get JSON on stdout; people get a summary on stderr.
20
- * When the project's skills don't match this CLI, a note on stderr says so, even when the command fails.
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
- let json = !terminal.stdoutIsTerminal || argv.includes("--json");
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 ||= parsed.options.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 Error(`Path escapes the project root: ${absolute}`);
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
- await appendJournal(deps, folder, "approve", { artifact: path, hash, ...(replanned.length > 0 ? { replanned } : {}), ...(rebased.length > 0 ? { rebased } : {}) });
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
- export async function listEvents(deps , event ) {
22
- const { journal } = await loadView(deps.files);
23
- return event === undefined ? journal : journal.filter((entry) => entry.event === event);
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
- return !i.archived && i.ref !== change.ref && bound !== undefined && (other === bound || other.endsWith(`/${bound}`));
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
+ }