@printpractical/cruze 0.0.2 → 0.0.4

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 (59) hide show
  1. package/README.md +11 -11
  2. package/dist/adapters/inbound/cli/argument_files.js +29 -0
  3. package/dist/adapters/inbound/cli/cli_context.js +1 -0
  4. package/dist/adapters/inbound/cli/command_options.js +20 -0
  5. package/dist/adapters/inbound/cli/commands/journal.js +3 -2
  6. package/dist/adapters/inbound/cli/commands/progress.js +7 -3
  7. package/dist/adapters/inbound/cli/commands/review.js +4 -3
  8. package/dist/adapters/inbound/cli/commands/status.js +3 -0
  9. package/dist/adapters/inbound/cli/commands.js +32 -20
  10. package/dist/adapters/inbound/cli/parse_command.js +8 -2
  11. package/dist/adapters/inbound/cli/run_cli.js +9 -5
  12. package/dist/adapters/outbound/node_project_files.js +4 -2
  13. package/dist/adapters/outbound/process_agent_runner.js +8 -3
  14. package/dist/app/ports/agent_runner.js +5 -6
  15. package/dist/app/use_cases/init_project.js +31 -3
  16. package/dist/app/use_cases/journal_events.js +12 -4
  17. package/dist/app/use_cases/project_status.js +8 -2
  18. package/dist/app/use_cases/record_progress.js +17 -0
  19. package/dist/app/use_cases/run_review.js +25 -11
  20. package/dist/domain/config.js +8 -8
  21. package/dist/domain/scaffold.js +15 -1
  22. package/dist/domain/status/work_status.js +1 -3
  23. package/package.json +1 -1
  24. package/skills/about/SKILL.md +1 -1
  25. package/skills/envision/SKILL.md +1 -1
  26. package/skills/envision/feature.md +1 -0
  27. package/skills/formats/reference/config.md +1 -1
  28. package/skills/formats/reference/durable-docs.md +1 -1
  29. package/skills/land/SKILL.md +1 -0
  30. package/skills/realign/notes/0.0.3.md +11 -0
  31. package/skills/realign/notes/0.0.4.md +27 -0
  32. package/skills/realign/notes.md +2 -0
  33. package/skills/rethink/levels.md +1 -1
  34. package/skills/roles/SKILL.md +1 -1
  35. package/src/adapters/inbound/cli/argument_files.ts +29 -0
  36. package/src/adapters/inbound/cli/cli_context.ts +1 -0
  37. package/src/adapters/inbound/cli/command_options.ts +20 -0
  38. package/src/adapters/inbound/cli/commands/journal.ts +3 -2
  39. package/src/adapters/inbound/cli/commands/progress.ts +7 -3
  40. package/src/adapters/inbound/cli/commands/review.ts +4 -3
  41. package/src/adapters/inbound/cli/commands/status.ts +3 -0
  42. package/src/adapters/inbound/cli/commands.ts +32 -20
  43. package/src/adapters/inbound/cli/parse_command.ts +8 -2
  44. package/src/adapters/inbound/cli/run_cli.ts +9 -5
  45. package/src/adapters/outbound/node_project_files.ts +4 -2
  46. package/src/adapters/outbound/process_agent_runner.ts +8 -3
  47. package/src/app/ports/agent_runner.ts +5 -6
  48. package/src/app/use_cases/init_project.ts +31 -3
  49. package/src/app/use_cases/journal_events.ts +12 -4
  50. package/src/app/use_cases/project_status.ts +8 -2
  51. package/src/app/use_cases/record_progress.ts +17 -0
  52. package/src/app/use_cases/run_review.ts +25 -11
  53. package/src/domain/config.ts +8 -8
  54. package/src/domain/scaffold.ts +15 -1
  55. package/src/domain/status/work_status.ts +1 -3
  56. package/templates/init/config.yaml +1 -6
  57. package/templates/init/review/claude.yaml +7 -0
  58. package/templates/init/review/other.yaml +7 -0
  59. package/templates/init/CLAUDE.md +0 -1
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.2 is the second preview. It adds upgrading and `realign`, and the fixes from the first real project's retro. 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.4 is the fourth preview. It fixes what the first projects on 0.0.3 ran into: commands print one format on stdout and refuse options they don't take, overlap checks count only work in flight, and the feature map can be corrected when a feature's decisions change it. Early development. The CLI is complete for the V1 lifecycle. The document formats ship as the `cruze-formats` skill, the standards an agent designs and codes to ship as knowledge skills, and the whole workflow, from explore to land, ships as workflow skills. Claude Code is the agent tested so far.
14
14
 
15
15
  ## Why
16
16
 
@@ -35,7 +35,7 @@ The skills run `cruze` commands, so the agent needs `cruze` on its path. Install
35
35
  From a GitHub Release, without npm. Each release attaches the packed package:
36
36
 
37
37
  ```sh
38
- npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.2/printpractical-cruze-0.0.2.tgz
38
+ npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.4/printpractical-cruze-0.0.4.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.2
50
+ npm install -g --install-links github:PrintPractical/cruze#v0.0.4
51
51
  ```
52
52
 
53
53
  ### 2. Set up a repository
@@ -61,25 +61,25 @@ 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.2/printpractical-cruze-0.0.2.tgz
64
+ cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.4/printpractical-cruze-0.0.4.tgz
65
65
  ```
66
66
 
67
- `init` asks for the project name, then creates:
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:
68
68
 
69
69
  | Path | Purpose |
70
70
  | --- | --- |
71
71
  | `README.md` | Titled with the project name |
72
72
  | `CHANGELOG.md` | In [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format |
73
73
  | `AGENTS.md` | Agent instructions; Cruze keeps its managed sections current |
74
- | `CLAUDE.md` | One line that imports `AGENTS.md`, for Claude Code |
75
- | `.cruze/config.yaml` | Project configuration, including the Cruze version whose skills the project uses |
74
+ | `CLAUDE.md` | For Claude Code only: a link to `AGENTS.md` |
75
+ | `.cruze/config.yaml` | Project configuration, including the Cruze version whose skills the project uses, and for Claude Code the command `cruze review` runs |
76
76
  | `.github/workflows/ci.yml` | CI that runs `cruze validate`, `check` and `trace`; the walking-skeleton change adds the language's build and test steps |
77
77
  | `.gitattributes` | Merges Cruze's journals line by line, so parallel branches don't conflict on them |
78
- | `.agents/skills/cruze-*` | The Cruze skills, linked into `.claude/skills/` for Claude Code |
78
+ | `.agents/skills/cruze-*` | The Cruze skills, also linked into `.claude/skills/` for Claude Code |
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
 
@@ -126,11 +126,11 @@ Skills call these at fixed points, and CI runs `check` and `trace` on every push
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 |
133
- | `cruze review <role>` | Runs a role (design reviewer, code reviewer, verifier, researcher or auditor) in a fresh agent context, through the command in `.cruze/config.yaml` |
133
+ | `cruze review <role>` | Runs a role (design reviewer, code reviewer, verifier, researcher or auditor) in a fresh agent context, through the command in `.cruze/config.yaml`. When no command is set, or its program isn't installed, it prints the role's prompt for the agent to run in a helper of its own |
134
134
  | `cruze land` | Merges a verified change into the living docs, re-stamps the approvals the merge would make stale, fills the Commands and Layout sections of `AGENTS.md`, and archives finished work |
135
135
  | `cruze realign status` | Lists what the installed Cruze expects of existing code since its `standards:` version, with what the CLI rules find now; `--full` covers every release |
136
136
  | `cruze realign done` | Moves `standards:` up once every finding is fixed, waived or scheduled in a change named with `--change` |
@@ -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
+ }
@@ -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 }),
@@ -14,5 +15,5 @@ export async function runReviewCommand(context , args , opti
14
15
  ...(blockers === undefined ? {} : { blockers }),
15
16
  ...(options.base === undefined ? {} : { base: options.base }),
16
17
  });
17
- return { json: report, human: report.report };
18
+ return { json: report, human: report.status === "reported" ? report.report : `${report.instruction}\n\n${report.prompt}` };
18
19
  }
@@ -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);
@@ -9,25 +9,36 @@ 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 <agent>] [--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: { handler: runApprove, options: ["replan", "rebase"], usage: "approve <vision|architecture|roadmap|ref|path> [--replan <ID>...] [--rebase <ID>...] record your approval of a document" },
26
+ new: {
27
+ handler: runNew,
28
+ options: { vision: [], glossary: [], architecture: [], roadmap: [], feature: ["title", "roadmap"], change: ["title", "feature", "roadmap"], adr: ["title"], note: ["title"] },
29
+ usage: "new <vision|glossary|architecture|roadmap> | new <feature|change|adr|note> <slug> --title <title> [--feature <ref>] [--roadmap <slug>]",
30
+ },
31
+ task: { handler: runTask, options: { done: ["change", "commit"], deviation: ["change"], reopen: ["change"] }, usage: "task <done|deviation|reopen> <T#> [text] [--change <ref>] [--commit <sha>]" },
32
+ features: { handler: runFeatures, options: { add: ["summary", "goals"], update: ["summary"], drop: ["reason"] }, usage: "features <add|update|drop> <slug> [--summary <text>] [--goals <ids>] [--reason <text>]" },
33
+ roadmap: { handler: runRoadmap, options: { prune: [] }, usage: "roadmap prune clear landed items from the roadmap status when a release closes" },
34
+ journal: { handler: runJournal, options: { add: ["set", "item"], list: ["event", "item"] }, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>] [--item <ref>]>" },
35
+ 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" },
36
+ feedback: { handler: runFeedback, options: { export: ["out", "no-redact"] }, usage: "feedback export [--out <file>] [--no-redact]" },
37
+ trace: { handler: runTrace, options: ["all", "change"], usage: "trace [--all] [--change <ref>] link scenarios to the tests that prove them" },
38
+ check: { handler: runCheck, options: ["file", "ci"], usage: "check [<file>...] [--ci] enforce layer rules and file budgets" },
39
+ abandon: { handler: runAbandon, options: ["reason"], usage: "abandon <ref> --reason <text> archive a feature or standalone change that stops for good" },
40
+ land: { handler: runLand, options: ["override"], usage: "land [<ref>] [--override <reason>] merge a verified change into the living docs" },
41
+ 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
42
  };
32
43
 
33
44
  export const USAGE = `Usage: cruze <command> [options]
@@ -35,4 +46,5 @@ export const USAGE = `Usage: cruze <command> [options]
35
46
  Commands:
36
47
  ${Object.values(COMMANDS).map((c) => ` ${c.usage}`).join("\n")}
37
48
 
38
- Every command prints JSON on stdout when it isn't a terminal, or with --json.`;
49
+ Every command prints its result on stdout: JSON when stdout isn't a terminal or with --json,
50
+ 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" },
@@ -53,13 +57,14 @@ export function parseCommand(argv ) {
53
57
  } catch (error) {
54
58
  throw new UsageError(error instanceof Error ? error.message : String(error));
55
59
  }
56
- const { values, positionals } = parsed;
60
+ const { values, positionals, tokens } = parsed;
57
61
  const optional = (key , value ) =>
58
62
  value === undefined ? {} : ({ [key]: value } );
59
63
  const options = {
60
64
  yes: values.yes,
61
65
  agent: values.agent,
62
66
  json: values.json,
67
+ text: values.text,
63
68
  set: values.set,
64
69
  redact: !values["no-redact"],
65
70
  all: values.all,
@@ -88,5 +93,6 @@ export function parseCommand(argv ) {
88
93
  ...optional("base", values.base),
89
94
  ...optional("blockers", values.blockers),
90
95
  };
91
- return { words: positionals, options, help: values.help, version: values.version };
96
+ const given = [...new Set(tokens.flatMap((token) => (token.kind === "option" ? [token.name] : [])))];
97
+ return { words: positionals, options, given, help: values.help, version: values.version };
92
98
  }
@@ -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 {
@@ -78,7 +79,8 @@ export class NodeProjectFiles {
78
79
  this.assertInsideRoot(resolve(dirname(linkPath), target));
79
80
  await mkdir(dirname(linkPath), { recursive: true });
80
81
  await rm(linkPath, { recursive: true, force: true });
81
- await symlink(target, linkPath, "dir");
82
+ // No link type: on Windows, Node picks a file or directory link from what the target is.
83
+ await symlink(target, linkPath);
82
84
  }
83
85
 
84
86
  /** Resolves a project-relative path, refusing anything outside the root. */
@@ -91,7 +93,7 @@ export class NodeProjectFiles {
91
93
  assertInsideRoot(absolute ) {
92
94
  const fromRoot = relative(this.root, absolute);
93
95
  if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
94
- throw new Error(`Path escapes the project root: ${absolute}`);
96
+ throw new CruzeError("outside-project", `${absolute} is outside the project at ${this.root}`);
95
97
  }
96
98
  }
97
99
  }
@@ -11,15 +11,20 @@ export class ProcessAgentRunner {
11
11
 
12
12
  run(command , prompt ) {
13
13
  const [program, ...args] = command;
14
- if (program === undefined) return Promise.resolve({ exitCode: 127, output: "", error: "no command configured" });
14
+ if (program === undefined) return Promise.resolve({ kind: "missing" });
15
15
  return new Promise((resolve) => {
16
16
  const child = spawn(program, args, { cwd: this.cwd, stdio: ["pipe", "pipe", "pipe"] });
17
17
  let output = "";
18
18
  let error = "";
19
19
  child.stdout.on("data", (chunk ) => (output += chunk.toString("utf8")));
20
20
  child.stderr.on("data", (chunk ) => (error += chunk.toString("utf8")));
21
- child.on("error", (failure) => resolve({ exitCode: 127, output, error: `${error}${failure.message}` }));
22
- child.on("close", (code) => resolve({ exitCode: code ?? 1, output, error }));
21
+ // Spawning reports ENOENT when the program isn't on the PATH.
22
+ child.on("error", (failure ) =>
23
+ resolve(failure.code === "ENOENT" ? { kind: "missing" } : { kind: "finished", exitCode: 127, output, error: `${error}${failure.message}` }),
24
+ );
25
+ child.on("close", (code) => resolve({ kind: "finished", exitCode: code ?? 1, output, error }));
26
+ // A program that never started closes stdin; ignore the write error, since the spawn error reports it.
27
+ child.stdin.on("error", () => {});
23
28
  child.stdin.end(prompt);
24
29
  });
25
30
  }
@@ -1,11 +1,10 @@
1
1
  /** Starts a coding agent in a fresh context, non-interactively, and collects what it reports. */
2
2
 
3
-
3
+
4
+
5
+
6
+
4
7
 
5
8
 
6
9
 
7
-
8
-
9
-
10
-
11
-
10
+
@@ -1,5 +1,7 @@
1
+ import { CruzeError } from "../../domain/cruze_error.js";
2
+ import { agentSkillHome } from "../../domain/agent_skill_homes.js";
1
3
  import { parseProjectName } from "../../domain/project_name.js";
2
- import { INIT_SCAFFOLD, renderTemplate } from "../../domain/scaffold.js";
4
+ import { CLAUDE_INSTRUCTIONS, INIT_AGENTS, INIT_SCAFFOLD, renderTemplate, reviewSettingTemplate } from "../../domain/scaffold.js";
3
5
 
4
6
 
5
7
 
@@ -10,6 +12,7 @@ import { installSkills, } from "./install_skills.js";
10
12
 
11
13
 
12
14
 
15
+
13
16
 
14
17
 
15
18
 
@@ -20,6 +23,7 @@ import { installSkills, } from "./install_skills.js";
20
23
 
21
24
 
22
25
 
26
+
23
27
 
24
28
 
25
29
 
@@ -36,11 +40,13 @@ export async function initProject(
36
40
  ) {
37
41
  const { files, bundle, prompter } = deps;
38
42
  const project = parseProjectName(request.name ?? (await prompter.ask("Project name", request.defaultName)));
43
+ const agent = await chooseAgent(files, prompter, request.agents);
39
44
  const values = {
40
45
  project_name: project,
41
46
  project_name_quoted: JSON.stringify(project),
42
47
  cruze_version: bundle.version,
43
48
  cruze_package: request.package ?? `@printpractical/cruze@${bundle.version}`,
49
+ review_setting: (await bundle.template(reviewSettingTemplate(agent))).trimEnd(),
44
50
  };
45
51
 
46
52
  const created = [];
@@ -53,7 +59,29 @@ export async function initProject(
53
59
  await files.writeText(entry.path, renderTemplate(await bundle.template(entry.template), values));
54
60
  created.push(entry.path);
55
61
  }
62
+ if (agent === "claude") {
63
+ if (await files.exists(CLAUDE_INSTRUCTIONS.path)) skipped.push(CLAUDE_INSTRUCTIONS.path);
64
+ else {
65
+ await files.link(CLAUDE_INSTRUCTIONS.path, CLAUDE_INSTRUCTIONS.target);
66
+ created.push(CLAUDE_INSTRUCTIONS.path);
67
+ }
68
+ }
56
69
 
57
- const skills = await installSkills({ files, bundle }, { agents: request.agents });
58
- return { project, created, skipped, skills };
70
+ const linkFor = new Set(request.agents.filter((a) => a !== "other"));
71
+ if (agent === "claude") linkFor.add("claude");
72
+ const skills = await installSkills({ files, bundle }, { agents: [...linkFor] });
73
+ return { project, agent, created, skipped, skills };
74
+ }
75
+
76
+ /** The agent named with `--agent`, or shown by the project's files, or else the answer to asking. */
77
+ async function chooseAgent(files , prompter , requested ) {
78
+ if (requested.includes("claude")) return "claude";
79
+ if (requested.includes("other")) return "other";
80
+ for (const marker of agentSkillHome("claude")?.markers ?? []) {
81
+ if (await files.exists(marker)) return "claude";
82
+ }
83
+ const answer = (await prompter.ask(`Coding agent (${INIT_AGENTS.join(" or ")})`, "claude")).trim().toLowerCase();
84
+ const agent = INIT_AGENTS.find((a) => a === answer);
85
+ if (agent === undefined) throw new CruzeError("unknown-agent", `Unknown agent: ${answer}. Answer ${INIT_AGENTS.join(" or ")}, or pass --agent.`);
86
+ return agent;
59
87
  }
@@ -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
+ }
@@ -58,6 +58,23 @@ export async function addFutureFeature(deps , slug , summary
58
58
  return { feature: slug };
59
59
  }
60
60
 
61
+ /**
62
+ * Replaces a feature's summary on the feature map, in the future list or the implemented list,
63
+ * when a decision changed what the feature delivers. Its goals or release stay as they are.
64
+ */
65
+ export async function updateFeatureSummary(deps , slug , summary ) {
66
+ if (summary.trim() === "") throw new CruzeError("missing-summary", "a feature needs a summary");
67
+ const vision = await requireVision(deps);
68
+ const future = readRows(vision, "Future").find((row) => row[0] === slug);
69
+ const implemented = readRows(vision, "Implemented").find((row) => row[0] === slug);
70
+ if (future === undefined && implemented === undefined) throw new CruzeError("not-found", `${slug} is not on the feature map`);
71
+ const list = future !== undefined ? "Future" : "Implemented";
72
+ const cells = future !== undefined ? [slug, summary.trim(), future[2] ?? ""] : [slug, implemented?.[1] ?? "", summary.trim()];
73
+ await deps.files.writeText(PATHS.vision, upsertRow(vision, list, cells));
74
+ await appendJournal(deps, ".cruze", "feature-map", { action: "update", feature: slug, summary: summary.trim() });
75
+ return { feature: slug, list };
76
+ }
77
+
61
78
  export async function dropFutureFeature(deps , slug , reason ) {
62
79
  const vision = await requireVision(deps);
63
80
  const { text, removed } = removeRow(vision, "Future", slug);