@printpractical/cruze 0.0.3 → 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 (40) hide show
  1. package/README.md +6 -6
  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 +3 -2
  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 +2 -1
  13. package/dist/app/use_cases/journal_events.js +12 -4
  14. package/dist/app/use_cases/project_status.js +8 -2
  15. package/dist/app/use_cases/record_progress.js +17 -0
  16. package/dist/domain/status/work_status.js +1 -3
  17. package/package.json +1 -1
  18. package/skills/about/SKILL.md +1 -1
  19. package/skills/envision/SKILL.md +1 -1
  20. package/skills/envision/feature.md +1 -0
  21. package/skills/formats/reference/durable-docs.md +1 -1
  22. package/skills/land/SKILL.md +1 -0
  23. package/skills/realign/notes/0.0.4.md +27 -0
  24. package/skills/realign/notes.md +1 -0
  25. package/skills/rethink/levels.md +1 -1
  26. package/src/adapters/inbound/cli/argument_files.ts +29 -0
  27. package/src/adapters/inbound/cli/cli_context.ts +1 -0
  28. package/src/adapters/inbound/cli/command_options.ts +20 -0
  29. package/src/adapters/inbound/cli/commands/journal.ts +3 -2
  30. package/src/adapters/inbound/cli/commands/progress.ts +7 -3
  31. package/src/adapters/inbound/cli/commands/review.ts +3 -2
  32. package/src/adapters/inbound/cli/commands/status.ts +3 -0
  33. package/src/adapters/inbound/cli/commands.ts +32 -20
  34. package/src/adapters/inbound/cli/parse_command.ts +8 -2
  35. package/src/adapters/inbound/cli/run_cli.ts +9 -5
  36. package/src/adapters/outbound/node_project_files.ts +2 -1
  37. package/src/app/use_cases/journal_events.ts +12 -4
  38. package/src/app/use_cases/project_status.ts +8 -2
  39. package/src/app/use_cases/record_progress.ts +17 -0
  40. package/src/domain/status/work_status.ts +1 -3
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.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.3/printpractical-cruze-0.0.3.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.3
50
+ npm install -g --install-links github:PrintPractical/cruze#v0.0.4
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.4/printpractical-cruze-0.0.4.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
 
@@ -126,7 +126,7 @@ 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 |
@@ -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 }),
@@ -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 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: { 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 {
@@ -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,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);
@@ -1,5 +1,4 @@
1
1
  import { evaluateApproval, } from "../approvals/evaluate.js";
2
- import { findIds } from "../ids.js";
3
2
  import { PATHS } from "../project/layout.js";
4
3
  import { readDelta } from "../project/deltas.js";
5
4
  import { readScope } from "../project/plan_parts.js";
@@ -89,13 +88,12 @@ export function buildGateReasons(view , branch )
89
88
  });
90
89
  }
91
90
 
92
- /** The elements a change touches: its scope plus every ID its documents cite. */
91
+ /** The elements a change touches: its scope and its delta, not the elements it only cites, such as a test seam. */
93
92
  export function touchedIds(view , change ) {
94
93
  const feature = featureOf(view.items, change);
95
94
  const scope = readScope(change.doc);
96
95
  const ids = new Set([...(scope?.delivers ?? []), ...(scope?.builds ?? []), ...(scope?.removes ?? [])]);
97
96
  const deltaDoc = feature?.doc ?? change.doc;
98
97
  for (const id of readDelta(deltaDoc).ids) ids.add(id);
99
- for (const id of findIds(change.doc.lines.join("\n"))) ids.add(id);
100
98
  return new Set([...ids].filter((id) => !id.startsWith("RULE-")));
101
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@printpractical/cruze",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Spec-driven development for coding agents: one living model of the system, small deltas, convergent review.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Chris Buchter",
@@ -9,7 +9,7 @@ Cruze is a spec-driven development framework. It keeps one living model of the s
9
9
 
10
10
  This installation is an early preview. It has:
11
11
 
12
- - The `cruze` CLI: `cruze init` sets up a repository, `cruze install` updates the skills after an upgrade, and `cruze help` lists the commands that validate, approve, check, trace and land work.
12
+ - The `cruze` CLI: `cruze init` sets up a repository, `cruze install` updates the skills after an upgrade, and `cruze help` lists the commands that validate, approve, check, trace and land work. Each prints its result on stdout, as JSON when stdout isn't a terminal, or as text with `--text`. A command refuses an option it doesn't take, so check `cruze help` when one is refused.
13
13
  - `cruze-formats`, the format of every Cruze document.
14
14
  - Knowledge skills holding the standards to design and code to: `cruze-hexagonal-design`, `cruze-behavioural-testing`, `cruze-grilling`, `cruze-domain-language`, `cruze-dependency-approval` and `cruze-research`.
15
15
  - The workflow: `explore` (optional), `envision` for the what, `architect` for the how, `roadmap` for the order of work, then `plan`, `build`, `verify` and `land` for each change. `triage` handles bugs, `rethink` steps back from anywhere, and `next` says what to do now.
@@ -21,7 +21,7 @@ Envision decides the what: the problem, who has it, and the behaviour that solve
21
21
  - Requirements and scenarios follow `.agents/skills/cruze-formats/reference/specs.md`. Every scenario uses concrete values: real names, real inputs, exact output. The scenarios become the tests, so their values are the expected results.
22
22
  - Keep implementation out: no modules, types, libraries or file formats unless the user names one as a constraint. When the user raises one, record it as a constraint or a settled decision and move on.
23
23
  - The vision stays within one page, excluding the feature map.
24
- - Only the CLI writes the feature map. Use `cruze features add` and `cruze features drop`.
24
+ - Only the CLI writes the feature map. Use `cruze features add`, `cruze features update` and `cruze features drop`.
25
25
 
26
26
  ## Next step
27
27
 
@@ -13,6 +13,7 @@ The result is a feature folder whose `feature.md` holds the intent, the settled
13
13
  - **Requirements:** each behaviour the feature adds, changes or removes, and the capability it belongs to. It goes in an existing capability when the behaviour fits one; otherwise it starts a new capability.
14
14
  - **Scenarios:** for each requirement, the main case, each edge and failure case a user will meet, and what they observe in each. Invent concrete edge cases to test the boundaries.
15
15
  6. **Grill in rounds** until every open decision is settled. Record decisions under `## Settled decisions` and terms in the glossary as they settle.
16
+ - When a decision changes something the feature's summary on the feature map promises, such as a policy it names, correct the summary with `cruze features update <slug> --summary <text>`.
16
17
  7. **Write the spec delta.** Use `ADDED` for new requirements, `MODIFIED` for a requirement whose text or scenarios change (writing its full new body with every scenario it keeps), `REMOVED` with a `Reason` and `Migration` for retired behaviour, and `ADDED CAPABILITY` for a new capability.
17
18
  - Name every ID with glossary terms. An ID is never reused.
18
19
  - Done when every requirement has a scenario for its main case and one for each failure a user can meet.
@@ -16,7 +16,7 @@ What the product is for. At most one page, excluding the feature map.
16
16
  | `## Feature map` | Managed. `### Implemented` is a table of Feature, Release and Summary. `### Future` is a table of Feature, Summary and Goals |
17
17
 
18
18
  - Feature-map entries are named by slug, such as `jump-hosts`. A future entry gets a folder ID only when its feature starts.
19
- - Only the CLI writes the feature map. `cruze features add` puts an entry on the future list, `cruze features drop` removes one, and `land` moves a finished feature to implemented, tagged with the release in progress.
19
+ - Only the CLI writes the feature map. `cruze features add` puts an entry on the future list, `cruze features update` corrects an entry's summary on either list, `cruze features drop` removes one from the future list, and `land` moves a finished feature to implemented, tagged with the release in progress.
20
20
 
21
21
  ## docs/glossary.md
22
22
 
@@ -20,6 +20,7 @@ Land makes finished work part of the living model. `cruze land` merges the chang
20
20
  - When they don't hold, stop and offer `rethink`.
21
21
  4. **Run `cruze land`** and quote its report: what it merged, what is now built, and whether the work finished and was archived.
22
22
  - A `merge-conflict` means someone changed an element this feature merged earlier. Stop and run `rethink`, which reconciles it.
23
+ - When the work finished, read the feature's row under `### Implemented` in `docs/vision.md` against its settled decisions and spec delta. When the summary no longer describes what landed, correct it with `cruze features update <slug> --summary <text>`.
23
24
  - A `merge-invalid` means the merged docs, or the feature and changes being landed, wouldn't validate, such as a decision citing an ID the change removes. Nothing was written. Stop and run `rethink`.
24
25
  5. **Update the changelog.** Under `## [Unreleased]` in `CHANGELOG.md`, add one line per user-visible behaviour the change delivers, in the user's words, under `### Added`, `### Changed` or `### Removed`. Name the behaviour, not the code, including any rule a user can now run into that no scenario covers yet.
25
26
  6. **Update the README** when the change altered how someone installs, runs or uses the product. Run every command you add, to check it works.
@@ -0,0 +1,27 @@
1
+ # Realign notes: 0.0.4
2
+
3
+ ## New work only
4
+
5
+ ### argument-files-anywhere: Files named on the command line may be outside the project
6
+ - Applies to: new work only
7
+ - Rule: `.agents/skills/cruze-roles/SKILL.md`, "Running a role"
8
+
9
+ ### cli-output-format: The CLI prints one format on stdout, JSON for agents and text with --text
10
+ - Applies to: new work only
11
+ - Rule: `.agents/skills/cruze-about/SKILL.md`
12
+
13
+ ### overlap-scope: Overlap counts only in-flight changes, and only the elements their scope and delta name
14
+ - Applies to: new work only
15
+ - Rule: `.agents/skills/cruze-plan/SKILL.md`, "Steps", step 7
16
+
17
+ ### refused-options: The CLI refuses an option its command doesn't take
18
+ - Applies to: new work only
19
+ - Rule: `.agents/skills/cruze-about/SKILL.md`
20
+
21
+ ### feature-map-summary: A feature's summary on the feature map follows its decisions
22
+ - Applies to: new work only
23
+ - Rule: `.agents/skills/cruze-land/SKILL.md`, "Landing a change", step 4
24
+
25
+ ### journal-list-item: Journal list narrows to one feature or change with --item
26
+ - Applies to: new work only
27
+ - Rule: `.agents/skills/cruze-about/SKILL.md`
@@ -6,6 +6,7 @@ Each release that changes what Cruze expects of a project ships a notes file, `n
6
6
 
7
7
  - [0.0.2](notes/0.0.2.md)
8
8
  - [0.0.3](notes/0.0.3.md)
9
+ - [0.0.4](notes/0.0.4.md)
9
10
 
10
11
  ## Format
11
12
 
@@ -30,7 +30,7 @@
30
30
 
31
31
  ## Vision
32
32
 
33
- 1. Edit `docs/vision.md` with the user: goals, non-goals or constraints. The feature map changes only through `cruze features add` and `cruze features drop`. When the problem, users or goals changed, update the overview under the title of `README.md` to match.
33
+ 1. Edit `docs/vision.md` with the user: goals, non-goals or constraints. The feature map changes only through `cruze features add`, `cruze features update` and `cruze features drop`. When the problem, users or goals changed, update the overview under the title of `README.md` to match.
34
34
  2. Update `docs/roadmap.md` to match, following `.agents/skills/cruze-roadmap/SKILL.md`.
35
35
  3. For each feature in progress that served a dropped goal, the user decides: keep it, or stop it with `cruze abandon <ref> --reason "<why>"`. Abandoning archives the folder and puts the feature back on the future list; changes that already landed stay.
36
36
  4. Re-approve with `cruze approve vision`, then `cruze approve roadmap`.
@@ -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.ts";
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: string): Promise<string> {
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: string, text: string): Promise<void> {
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: unknown): string {
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
+ }
@@ -29,6 +29,7 @@ export interface Options {
29
29
  yes: boolean;
30
30
  agent: string[];
31
31
  json: boolean;
32
+ text: boolean;
32
33
  title?: string;
33
34
  feature?: string;
34
35
  roadmap?: string;
@@ -0,0 +1,20 @@
1
+ import { UsageError } from "./cli_context.ts";
2
+ import type { Command } from "./commands.ts";
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: string, args: string[], command: Command, given: string[]): void {
12
+ const sub = Array.isArray(command.options) ? undefined : args[0];
13
+ const taken = Array.isArray(command.options) ? command.options : sub === undefined ? undefined : (command.options as Record<string, readonly string[]>)[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.ts";
2
+ import { writeArgumentFile } from "../argument_files.ts";
2
3
  import { UsageError, requireArgs, type CliContext, type CommandResult, type Options } from "../cli_context.ts";
3
4
 
4
5
  export async function runJournal(context: CliContext, args: string[], options: Options): Promise<CommandResult> {
@@ -14,7 +15,7 @@ export async function runJournal(context: CliContext, args: string[], options: O
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: CliContext, args: string[], 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.ts";
1
+ import { addFutureFeature, completeTask, dropFutureFeature, updateFeatureSummary, pruneRoadmap, recordDeviation, reopenTask } from "../../../../app/use_cases/record_progress.ts";
2
2
  import { abandonWork } from "../../../../app/use_cases/abandon_work.ts";
3
3
  import { UsageError, requireArgs, requireOption, type CliContext, type CommandResult, type Options } from "../cli_context.ts";
4
4
 
@@ -23,17 +23,21 @@ export async function runTask(context: CliContext, args: string[], options: Opti
23
23
  }
24
24
 
25
25
  export async function runFeatures(context: CliContext, args: string[], options: Options): Promise<CommandResult> {
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: CliContext, args: string[]): Promise<CommandResult> {
@@ -1,12 +1,13 @@
1
1
  import { runReview } from "../../../../app/use_cases/run_review.ts";
2
+ import { readArgumentFile } from "../argument_files.ts";
2
3
  import { UsageError, requireArgs, type CliContext, type CommandResult, type Options } from "../cli_context.ts";
3
4
 
4
5
  export async function runReviewCommand(context: CliContext, args: string[], options: Options): Promise<CommandResult> {
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 type { ApprovalStatus } from "../../../../domain/approvals/evaluate.ts";
3
3
  import { UsageError, type CliContext, type CommandResult, type Options } from "../cli_context.ts";
4
4
 
5
5
  export async function runStatus(context: CliContext, _args: string[], options: Options): Promise<CommandResult> {
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.ts";
10
10
  import { runNext, runStatus } from "./commands/status.ts";
11
11
 
12
- export const COMMANDS: Record<string, { handler: Handler; usage: string }> = {
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
+ export interface Command {
14
+ handler: Handler;
15
+ usage: string;
16
+ options: readonly string[] | Readonly<Record<string, readonly string[]>>;
17
+ }
18
+
19
+ export const COMMANDS: Record<string, Command> = {
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 @@ export interface ParsedCommand {
5
5
  /** The command and, for grouped commands, its subcommand, such as `new feature`. */
6
6
  words: string[];
7
7
  options: Options;
8
+ /** The options given on the command line, by long name, so a command can refuse ones it doesn't take. */
9
+ given: string[];
8
10
  help: boolean;
9
11
  version: boolean;
10
12
  }
@@ -15,11 +17,13 @@ export function parseCommand(argv: string[]): ParsedCommand {
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: string[]): ParsedCommand {
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 = <K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> =>
58
62
  value === undefined ? {} : ({ [key]: value } as Record<K, string>);
59
63
  const options: 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: string[]): ParsedCommand {
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.ts";
2
2
  import { CruzeError } from "../../../domain/cruze_error.ts";
3
3
  import { type CliContext, UsageError } from "./cli_context.ts";
4
+ import { refuseUnknownOptions } from "./command_options.ts";
4
5
  import { COMMANDS, USAGE } from "./commands.ts";
5
6
  import { parseCommand } from "./parse_command.ts";
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: string[], context: CliContext, terminal: Terminal): Promise<number> {
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: string | undefined;
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: string[], context: CliContext, terminal: Term
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
  import type { ProjectFiles } from "../../app/ports/project_files.ts";
4
+ import { CruzeError } from "../../domain/cruze_error.ts";
4
5
 
5
6
  /** Project files on the local disk, confined to one root directory. */
6
7
  export class NodeProjectFiles implements ProjectFiles {
@@ -92,7 +93,7 @@ export class NodeProjectFiles implements ProjectFiles {
92
93
  private assertInsideRoot(absolute: string): void {
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,5 +1,5 @@
1
1
  import { CruzeError } from "../../domain/cruze_error.ts";
2
- import { recordedEventProblems, recordedFields, type JournalEntry } from "../../domain/journal.ts";
2
+ import { journalPath, parseJournal, recordedEventProblems, recordedFields, type JournalEntry } from "../../domain/journal.ts";
3
3
  import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
4
4
  import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
5
5
 
@@ -18,9 +18,17 @@ export async function recordEvent(deps: ProjectDeps, event: string, fields: Reco
18
18
  return appendJournal(deps, folder, event, { ...recorded, cruze: deps.bundle.version });
19
19
  }
20
20
 
21
- export async function listEvents(deps: ProjectDeps, event?: string): Promise<JournalEntry[]> {
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: ProjectDeps, event?: string, item?: string): Promise<JournalEntry[]> {
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
  export interface FeedbackExport {
@@ -1,6 +1,7 @@
1
1
  import { CruzeError } from "../../domain/cruze_error.ts";
2
2
  import { readProgress } from "../../domain/project/progress.ts";
3
3
  import { buildProjectView } from "../../domain/project/project_view.ts";
4
+ import { findWorkItem, type WorkItem } from "../../domain/project/work_items.ts";
4
5
  import { nextSteps, type NextReport } from "../../domain/status/next_step.ts";
5
6
  import { activeChange, buildGateReasons, projectStatus, touchedIds, type ProjectStatus } from "../../domain/status/work_status.ts";
6
7
  import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
@@ -59,10 +60,11 @@ export async function findOverlaps(deps: ProjectDeps, ref?: string): Promise<{ c
59
60
  const overlaps: Overlap[] = [];
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: ProjectDeps, ref?: string): Promise<{ c
71
73
  }
72
74
  return { change: change.ref, overlaps };
73
75
  }
76
+
77
+ function landed(item: WorkItem): boolean {
78
+ return item.archived || readProgress(item.doc).landed !== undefined;
79
+ }
@@ -58,6 +58,23 @@ export async function addFutureFeature(deps: ProjectDeps, slug: string, 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: ProjectDeps, slug: string, summary: string): Promise<{ feature: string; list: "Future" | "Implemented" }> {
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: ProjectDeps, slug: string, reason: string): Promise<{ feature: string }> {
62
79
  const vision = await requireVision(deps);
63
80
  const { text, removed } = removeRow(vision, "Future", slug);
@@ -1,5 +1,4 @@
1
1
  import { evaluateApproval, type ApprovalStatus } from "../approvals/evaluate.ts";
2
- import { findIds } from "../ids.ts";
3
2
  import { PATHS } from "../project/layout.ts";
4
3
  import { readDelta } from "../project/deltas.ts";
5
4
  import { readScope } from "../project/plan_parts.ts";
@@ -89,13 +88,12 @@ export function buildGateReasons(view: ProjectView, branch: string | null): stri
89
88
  });
90
89
  }
91
90
 
92
- /** The elements a change touches: its scope plus every ID its documents cite. */
91
+ /** The elements a change touches: its scope and its delta, not the elements it only cites, such as a test seam. */
93
92
  export function touchedIds(view: ProjectView, change: WorkItem): Set<string> {
94
93
  const feature = featureOf(view.items, change);
95
94
  const scope = readScope(change.doc);
96
95
  const ids = new Set([...(scope?.delivers ?? []), ...(scope?.builds ?? []), ...(scope?.removes ?? [])]);
97
96
  const deltaDoc = feature?.doc ?? change.doc;
98
97
  for (const id of readDelta(deltaDoc).ids) ids.add(id);
99
- for (const id of findIds(change.doc.lines.join("\n"))) ids.add(id);
100
98
  return new Set([...ids].filter((id) => !id.startsWith("RULE-")));
101
99
  }