@printpractical/cruze 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +10 -10
  2. package/dist/adapters/inbound/cli/argument_files.js +29 -0
  3. package/dist/adapters/inbound/cli/cli_context.js +3 -0
  4. package/dist/adapters/inbound/cli/command_options.js +20 -0
  5. package/dist/adapters/inbound/cli/commands/documents.js +5 -1
  6. package/dist/adapters/inbound/cli/commands/journal.js +3 -2
  7. package/dist/adapters/inbound/cli/commands/progress.js +7 -3
  8. package/dist/adapters/inbound/cli/commands/review.js +3 -2
  9. package/dist/adapters/inbound/cli/commands/status.js +7 -1
  10. package/dist/adapters/inbound/cli/commands.js +36 -20
  11. package/dist/adapters/inbound/cli/parse_command.js +12 -2
  12. package/dist/adapters/inbound/cli/run_cli.js +9 -5
  13. package/dist/adapters/outbound/node_project_files.js +2 -1
  14. package/dist/app/use_cases/approve_artifact.js +26 -1
  15. package/dist/app/use_cases/journal_events.js +12 -4
  16. package/dist/app/use_cases/project_status.js +8 -2
  17. package/dist/app/use_cases/record_progress.js +17 -0
  18. package/dist/domain/approvals/agent_approval.js +68 -0
  19. package/dist/domain/config.js +30 -2
  20. package/dist/domain/journal.js +5 -0
  21. package/dist/domain/status/verification.js +3 -2
  22. package/dist/domain/status/work_status.js +1 -3
  23. package/dist/domain/validation/plan_rules.js +2 -0
  24. package/dist/domain/validation/size_rules.js +36 -0
  25. package/dist/domain/validation/work_item_rules.js +2 -0
  26. package/dist/domain/versions/rule_versions.js +3 -0
  27. package/package.json +1 -1
  28. package/skills/about/SKILL.md +1 -1
  29. package/skills/architect/feature.md +7 -2
  30. package/skills/envision/SKILL.md +1 -1
  31. package/skills/envision/feature.md +1 -0
  32. package/skills/formats/SKILL.md +1 -1
  33. package/skills/formats/reference/config.md +10 -0
  34. package/skills/formats/reference/durable-docs.md +1 -1
  35. package/skills/formats/reference/work-items.md +1 -1
  36. package/skills/land/SKILL.md +1 -0
  37. package/skills/plan/SKILL.md +3 -3
  38. package/skills/realign/notes/0.0.4.md +27 -0
  39. package/skills/realign/notes/0.0.5.md +38 -0
  40. package/skills/realign/notes.md +2 -0
  41. package/skills/rethink/SKILL.md +5 -1
  42. package/skills/rethink/levels.md +4 -4
  43. package/skills/roles/SKILL.md +17 -7
  44. package/skills/roles/code-reviewer.md +1 -1
  45. package/skills/roles/design-reviewer.md +2 -2
  46. package/skills/verify/SKILL.md +1 -1
  47. package/src/adapters/inbound/cli/argument_files.ts +29 -0
  48. package/src/adapters/inbound/cli/cli_context.ts +3 -0
  49. package/src/adapters/inbound/cli/command_options.ts +20 -0
  50. package/src/adapters/inbound/cli/commands/documents.ts +5 -1
  51. package/src/adapters/inbound/cli/commands/journal.ts +3 -2
  52. package/src/adapters/inbound/cli/commands/progress.ts +7 -3
  53. package/src/adapters/inbound/cli/commands/review.ts +3 -2
  54. package/src/adapters/inbound/cli/commands/status.ts +7 -1
  55. package/src/adapters/inbound/cli/commands.ts +36 -20
  56. package/src/adapters/inbound/cli/parse_command.ts +12 -2
  57. package/src/adapters/inbound/cli/run_cli.ts +9 -5
  58. package/src/adapters/outbound/node_project_files.ts +2 -1
  59. package/src/app/use_cases/approve_artifact.ts +26 -1
  60. package/src/app/use_cases/journal_events.ts +12 -4
  61. package/src/app/use_cases/project_status.ts +8 -2
  62. package/src/app/use_cases/record_progress.ts +17 -0
  63. package/src/domain/approvals/agent_approval.ts +68 -0
  64. package/src/domain/config.ts +30 -2
  65. package/src/domain/journal.ts +5 -0
  66. package/src/domain/status/verification.ts +3 -2
  67. package/src/domain/status/work_status.ts +1 -3
  68. package/src/domain/validation/plan_rules.ts +2 -0
  69. package/src/domain/validation/size_rules.ts +36 -0
  70. package/src/domain/validation/work_item_rules.ts +2 -0
  71. package/src/domain/versions/rule_versions.ts +3 -0
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cruze-roles
3
- description: Prompts for Cruze's fresh-context roles (the design reviewer, code reviewer, verifier and researcher), how to run one, and how to take a review through its two rounds to a disposition for every finding. Use when a Cruze workflow step says to run a role, a review or research in a fresh context.
3
+ description: Prompts for Cruze's fresh-context roles (the design reviewer, code reviewer, verifier and researcher), how to run one, and how to take a review through its two rounds to a disposition for every finding, with the user deciding only the exceptions. Use when a Cruze workflow step says to run a role, a review or research in a fresh context.
4
4
  ---
5
5
 
6
6
  # Roles
@@ -30,16 +30,26 @@ A review has at most two rounds. Only blockers can force the second one.
30
30
 
31
31
  1. Commit the documents under review (`docs: <artifact> for design review`), so the fixes in round 2 have a diff. Then run the reviewer on the complete artifact.
32
32
  2. Record the round: `cruze journal add review --set review=<design|plan|code> --set round=1 --set blockers=<n> --set concerns=<n> --set nits=<n>`, with the counts from the report's last line, adding `--item <ref>` for a feature or change.
33
- 3. Present every finding to the user with your recommended disposition and why:
34
- - `fixed`: you will change the design as the finding proposes.
33
+ 3. **Sort the findings.** A finding is an **exception**, which the user decides, when any of these holds:
34
+ - Its fix changes behaviour: it adds, removes or rewrites a requirement or scenario, or changes what a person sees or does beyond what the spec says. Making the code or the plan meet a scenario as written is not a change of behaviour.
35
+ - Its fix changes a port, a port's `Operations` or a public contract that the approved design names.
36
+ - Its fix changes another decision the grilling skill leaves to the user: a context, dependency direction or a dependency.
37
+ - You recommend anything but `fixed`: waiving, deferring or rejecting it, or a rethink because it needs a design change.
38
+ - You would fix it differently from the reviewer's proposed fix.
39
+
40
+ Every other finding is yours, blockers included: you fix it as the reviewer proposes. When `review.decide` in `.cruze/config.yaml` is `all`, every finding is an exception.
41
+ 4. **Present them in one message.** First each exception, with your recommended disposition and why:
42
+ - `fixed`: you will change the work as the finding proposes.
35
43
  - `waived`: the finding is right, but the user accepts the risk.
36
44
  - `deferred`: it belongs to later work. When it is future scope, add it with `cruze features add <slug> --summary <text> --goals <ids>`.
37
45
  - `rejected`: the finding is wrong, with the reason.
38
46
 
39
47
  A finding whose fix would change an approved element, such as one marked `Needs design change`, is never `deferred` or `waived`. Recommend `rethink` at that element's level now. Once the rethink is done and the code follows it, record the finding as `fixed`.
40
- 4. The user decides each one. Record each decision: `cruze journal add disposition --set finding="<rubric item>: <summary>" --set disposition=<fixed|waived|deferred|rejected> --set reason="<reason>" --set review=<design|plan|code>`, with the same `--item`. A recorded disposition is never raised again.
41
- 5. Apply every `fixed` change, then run `cruze validate`. Done when it reports no errors.
42
- 6. When a finding could be caught by a tool, such as a layering, naming or size rule, tell the user which `cruze check` rule or linter setting would catch it next time.
48
+
49
+ Then one line for each finding you are fixing yourself. When there are no exceptions, say what you are fixing and carry on without waiting. The user can object to any line until the work is approved, and that finding becomes an exception: revert its fix and let them decide it.
50
+ 5. **Record every disposition:** `cruze journal add disposition --set finding="<rubric item>: <summary>" --set disposition=<fixed|waived|deferred|rejected> --set reason="<reason>" --set review=<design|plan|code> --set decided=<agent|user>`, with the same `--item`. Use `decided=user` for each exception, once the user decides it, and `decided=agent` for each finding you fixed yourself. The CLI refuses `decided=agent` for anything but `fixed`. A recorded disposition is never raised again.
51
+ 6. Apply every `fixed` change, then run `cruze validate`. Done when it reports no errors.
52
+ 7. When a finding could be caught by a tool, such as a layering, naming or size rule, tell the user which `cruze check` rule or linter setting would catch it next time.
43
53
 
44
54
  ### Round 2
45
55
 
@@ -49,7 +59,7 @@ Run round 2 only when a round-1 blocker was fixed.
49
59
  2. Record the round with `round=2`.
50
60
  3. Take any blocker still open to the user, who fixes it now, waives it or starts a rethink. There is no round 3.
51
61
 
52
- Done when every finding has a journaled disposition and no blocker is open without the user's decision.
62
+ Done when every finding has a journaled disposition, and every exception, and every blocker left open in round 2, has the user's decision.
53
63
 
54
64
  ## The settled ledger
55
65
 
@@ -46,7 +46,7 @@ Each finding has this shape:
46
46
  ```
47
47
 
48
48
  - A **blocker** means a scenario fails or is untested, a protected test was weakened, a layering rule is broken, an unapproved dependency was added, or an adapter can't honour its port's `Operations`. A finding that needs a design change is a blocker. Everything else is a **concern**.
49
- - List every blocker. List at most 5 concerns, the most important first. Count the rest as nits without listing them.
49
+ - List every blocker. List at most 5 concerns, or one per 5 tasks of the change when that is more, the most important first. Count the rest as nits without listing them. Report only what would go wrong: no concerns is a valid report, and a small change usually has few.
50
50
  - Drop any finding that contradicts the settled ledger. The only exception is a blocker with new evidence, which you mark `New evidence:` and explain.
51
51
  - When a finding could be caught mechanically, add `Check: <the cruze check rule or linter setting that would catch it>`.
52
52
  - In round 2, report only whether each round-1 blocker is fixed, as `fixed` or `open` with the reason. Raise nothing new.
@@ -29,7 +29,7 @@ Review against these items and nothing else. Each finding names one.
29
29
  - each use case's `Output` and `Errors` against the `FLOW`s that serve it and the `XC` rules that apply to it;
30
30
  - each adapter's duties against the port it implements and the `XC` rules it follows;
31
31
  - each guarantee, such as "always fits" or "never returns", against the limits and failures that bound it.
32
- 9. **Slicing.** Each change is a vertical slice that delivers scenarios end to end and can land on its own. The order respects `Depends on`, and the first change is the thinnest useful tracer bullet. A change `Builds` an element only when its delivered scenarios prove the whole body; an element whose duties arrive in several changes is split into elements that each arrive whole. `Depends on` names every earlier change that builds something this change's scenarios need. At project scope, the roadmap starts with the walking skeleton, and every goal in the release is covered.
32
+ 9. **Slicing.** Each change is a vertical slice that delivers scenarios end to end and can land on its own. The order respects `Depends on`, and the first change proves the design end to end. Each change carries enough work to be worth its own plan review, code review and verify: changes that build one or two elements each on the same adapters or screens, in a chain, are one change split too fine. A change that one verifier run can't hold is split too coarse. A change `Builds` an element only when its delivered scenarios prove the whole body; an element whose duties arrive in several changes is split into elements that each arrive whole. `Depends on` names every earlier change that builds something this change's scenarios need. At project scope, the roadmap starts with the walking skeleton, and every goal in the release is covered.
33
33
  10. **Ambiguity.** Name each point where a fresh implementer could reasonably build something different from what the user expects.
34
34
 
35
35
  ## Plan rubric
@@ -56,7 +56,7 @@ Each finding has this shape:
56
56
  ```
57
57
 
58
58
  - A **blocker** means building from the design as written would produce wrong behaviour, a layering violation or an untestable scenario. Two elements that contradict each other are always a blocker, because an implementer has to pick one. Everything else is a **concern**.
59
- - List every blocker. List at most 5 concerns, the most important first. Count the rest as nits without listing them.
59
+ - List every blocker. List at most 5 concerns, or for a plan one per 5 tasks when that is more, the most important first. Count the rest as nits without listing them. Report only what would go wrong: no concerns is a valid report, and a small artifact usually has few.
60
60
  - Drop any finding that contradicts the settled ledger. The only exception is a blocker with new evidence, which you mark `New evidence:` and explain.
61
61
  - When a finding could be caught mechanically, add `Check: <the cruze check rule or linter setting that would catch it>`.
62
62
  - In round 2, report only whether each round-1 blocker is fixed, as `fixed` or `open` with the reason. Raise nothing new.
@@ -13,7 +13,7 @@ Verify answers one question: does this change work when someone uses it? Green t
13
13
  2. **Run the mechanical checks.** Run the project's format, lint, build and test commands from `commands:` in `.cruze/config.yaml`, then `cruze validate`, `cruze check --ci` and `cruze trace --change <ref>`. Quote each result. When one fails, fix the code in a commit and run them again. When a fix needs design, stop and offer `rethink`.
14
14
  3. **Check the dependencies.** Follow "Checking" in `.agents/skills/cruze-dependency-approval/SKILL.md` against the base commit. An unrecorded dependency is a blocker.
15
15
  4. **Run the verifier.** Run the verifier role from `.agents/skills/cruze-roles/SKILL.md` for the change. It must be able to build and run the system. When it runs through `cruze review`, check that `review.command` in `.cruze/config.yaml` allows the project's build and run commands, and add them with the user's agreement. Done when you have its report, with a result for every delivered scenario.
16
- 5. **Review the code.** Run the review in the roles skill with the code reviewer, `review=code`, `--item <ref>` and the base commit. Fix each finding the user marks `fixed` in a commit, then check the fixes in round 2.
16
+ 5. **Review the code.** Run the review in the roles skill with the code reviewer, `review=code`, `--item <ref>` and the base commit. Fix each finding whose disposition is `fixed` in a commit, then check the fixes in round 2.
17
17
  6. **Write the manual test script** as `manual-test.md` in the change folder, following [manual-test.md](manual-test.md). Commit it.
18
18
  7. **Report.** Show the user four things:
19
19
  - The mechanical checks, one line each.
@@ -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;
@@ -51,6 +52,8 @@ export interface Options {
51
52
  full: boolean;
52
53
  replan: string[];
53
54
  rebase: string[];
55
+ byAgent: boolean;
56
+ restampUnchanged: boolean;
54
57
  round?: string;
55
58
  package?: string;
56
59
  base?: 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
+ }
@@ -13,8 +13,12 @@ export async function runValidate(context: CliContext): Promise<CommandResult> {
13
13
 
14
14
  export async function runApprove(context: CliContext, args: string[], options: Options): Promise<CommandResult> {
15
15
  requireArgs(args, ["document"]);
16
- const report = await approveArtifact(context, args[0] ?? "", { replan: options.replan, rebase: options.rebase });
16
+ if (options.byAgent && options.restampUnchanged) throw new UsageError("--by-agent and --restamp-unchanged are alternatives");
17
+ const agent = options.byAgent ? "plan-review" : options.restampUnchanged ? "rethink" : undefined;
18
+ const report = await approveArtifact(context, args[0] ?? "", { replan: options.replan, rebase: options.rebase, ...(agent === undefined ? {} : { agent }) });
17
19
  const lines = [`Approved ${report.artifact} (${report.hash.slice(0, 19)}), pinned to ${report.upstream.length} upstream element(s).`];
20
+ if (report.basis === "plan-review") lines.push("Approved for the user: its plan review closed with no blocker open.");
21
+ if (report.restamped !== undefined) lines.push(`Re-stamped for the user: its own design is unchanged, and only ${report.restamped.join(", ")} changed.`);
18
22
  if (report.stamped.length > 0) lines.push(`Marked ${report.stamped.length} element(s) planned.`);
19
23
  if (report.bound !== undefined) lines.push(`Bound the change to branch ${report.bound}.`);
20
24
  if (report.replanned.length > 0) lines.push(`Set back to planned: ${report.replanned.join(", ")}.`);
@@ -1,4 +1,5 @@
1
1
  import { exportFeedback, listEvents, recordEvent } from "../../../../app/use_cases/journal_events.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);
@@ -32,8 +35,11 @@ export async function runStatus(context: CliContext, _args: string[], options: O
32
35
  return { json: report, human: lines.join("\n") };
33
36
  }
34
37
 
38
+ const BASIS: Record<string, string> = { "plan-review": " (by the agent, after its plan review)", rethink: " (re-stamped by the agent after a rethink)" };
39
+
35
40
  function describe(label: string, status: ApprovalStatus): string {
36
- const detail = status.changed.length > 0 ? ` (${status.changed.join(", ")})` : status.unjournaled === true ? " (no journal entry)" : "";
41
+ const byAgent = status.state === "approved" ? BASIS[status.record?.basis ?? ""] ?? "" : "";
42
+ const detail = status.changed.length > 0 ? ` (${status.changed.join(", ")})` : status.unjournaled === true ? " (no journal entry)" : byAgent;
37
43
  return `${label}: ${status.state}${detail}`;
38
44
  }
39
45
 
@@ -9,25 +9,40 @@ import { runAbandon, runFeatures, runRoadmap, runTask } from "./commands/progres
9
9
  import { runReviewCommand } from "./commands/review.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: {
26
+ handler: runApprove,
27
+ options: ["replan", "rebase", "by-agent", "restamp-unchanged"],
28
+ usage: "approve <vision|architecture|roadmap|ref|path> [--replan <ID>...] [--rebase <ID>...] [--by-agent|--restamp-unchanged] record an approval of a document",
29
+ },
30
+ new: {
31
+ handler: runNew,
32
+ options: { vision: [], glossary: [], architecture: [], roadmap: [], feature: ["title", "roadmap"], change: ["title", "feature", "roadmap"], adr: ["title"], note: ["title"] },
33
+ usage: "new <vision|glossary|architecture|roadmap> | new <feature|change|adr|note> <slug> --title <title> [--feature <ref>] [--roadmap <slug>]",
34
+ },
35
+ task: { handler: runTask, options: { done: ["change", "commit"], deviation: ["change"], reopen: ["change"] }, usage: "task <done|deviation|reopen> <T#> [text] [--change <ref>] [--commit <sha>]" },
36
+ features: { handler: runFeatures, options: { add: ["summary", "goals"], update: ["summary"], drop: ["reason"] }, usage: "features <add|update|drop> <slug> [--summary <text>] [--goals <ids>] [--reason <text>]" },
37
+ roadmap: { handler: runRoadmap, options: { prune: [] }, usage: "roadmap prune clear landed items from the roadmap status when a release closes" },
38
+ journal: { handler: runJournal, options: { add: ["set", "item"], list: ["event", "item"] }, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>] [--item <ref>]>" },
39
+ review: { handler: runReviewCommand, options: ["item", "round", "blockers", "base"], usage: "review <role> [--item <ref>] [--round 1|2 --blockers <file>] [--base <commit>] run a role in a fresh agent context" },
40
+ feedback: { handler: runFeedback, options: { export: ["out", "no-redact"] }, usage: "feedback export [--out <file>] [--no-redact]" },
41
+ trace: { handler: runTrace, options: ["all", "change"], usage: "trace [--all] [--change <ref>] link scenarios to the tests that prove them" },
42
+ check: { handler: runCheck, options: ["file", "ci"], usage: "check [<file>...] [--ci] enforce layer rules and file budgets" },
43
+ abandon: { handler: runAbandon, options: ["reason"], usage: "abandon <ref> --reason <text> archive a feature or standalone change that stops for good" },
44
+ land: { handler: runLand, options: ["override"], usage: "land [<ref>] [--override <reason>] merge a verified change into the living docs" },
45
+ realign: { handler: runRealign, options: { status: ["full"], done: ["change"] }, usage: "realign <status [--full]|done [--change <ref>]> what the installed Cruze expects of existing code, and recording that the code meets it" },
31
46
  };
32
47
 
33
48
  export const USAGE = `Usage: cruze <command> [options]
@@ -35,4 +50,5 @@ export const USAGE = `Usage: cruze <command> [options]
35
50
  Commands:
36
51
  ${Object.values(COMMANDS).map((c) => ` ${c.usage}`).join("\n")}
37
52
 
38
- Every command prints JSON on stdout when it isn't a terminal, or with --json.`;
53
+ Every command prints its result on stdout: JSON when stdout isn't a terminal or with --json,
54
+ and text on a terminal or with --text. Errors and notes go to stderr.`;
@@ -5,6 +5,8 @@ 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" },
@@ -48,18 +52,21 @@ export function parseCommand(argv: string[]): ParsedCommand {
48
52
  rebase: { type: "string", multiple: true, default: [] },
49
53
  base: { type: "string" },
50
54
  blockers: { type: "string" },
55
+ "by-agent": { type: "boolean", default: false },
56
+ "restamp-unchanged": { type: "boolean", default: false },
51
57
  },
52
58
  });
53
59
  } catch (error) {
54
60
  throw new UsageError(error instanceof Error ? error.message : String(error));
55
61
  }
56
- const { values, positionals } = parsed;
62
+ const { values, positionals, tokens } = parsed;
57
63
  const optional = <K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> =>
58
64
  value === undefined ? {} : ({ [key]: value } as Record<K, string>);
59
65
  const options: Options = {
60
66
  yes: values.yes,
61
67
  agent: values.agent,
62
68
  json: values.json,
69
+ text: values.text,
63
70
  set: values.set,
64
71
  redact: !values["no-redact"],
65
72
  all: values.all,
@@ -69,6 +76,8 @@ export function parseCommand(argv: string[]): ParsedCommand {
69
76
  full: values.full,
70
77
  replan: values.replan,
71
78
  rebase: values.rebase,
79
+ byAgent: values["by-agent"],
80
+ restampUnchanged: values["restamp-unchanged"],
72
81
  ...optional("name", values.name),
73
82
  ...optional("title", values.title),
74
83
  ...optional("feature", values.feature),
@@ -88,5 +97,6 @@ export function parseCommand(argv: string[]): ParsedCommand {
88
97
  ...optional("base", values.base),
89
98
  ...optional("blockers", values.blockers),
90
99
  };
91
- return { words: positionals, options, help: values.help, version: values.version };
100
+ const given = [...new Set(tokens.flatMap((token) => (token.kind === "option" ? [token.name] : [])))];
101
+ return { words: positionals, options, given, help: values.help, version: values.version };
92
102
  }
@@ -1,6 +1,7 @@
1
1
  import { versionNotice } from "../../../app/use_cases/record_version.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,3 +1,4 @@
1
+ import { requirePlanReviewClosed, requireUnchangedSinceApproval, type AgentBasis } from "../../domain/approvals/agent_approval.ts";
1
2
  import { evaluateApproval } from "../../domain/approvals/evaluate.ts";
2
3
  import { fingerprint, isApprovable } from "../../domain/approvals/fingerprint.ts";
3
4
  import { latestApproval, serializeApprovals, withApproval, type ApprovalRecord } from "../../domain/approvals/records.ts";
@@ -29,6 +30,10 @@ export interface ApproveReport {
29
30
  replanned: string[];
30
31
  /** Elements whose current living text this feature accepted after a land conflict. */
31
32
  rebased: string[];
33
+ /** Why the agent gave this approval for the user, when it did. */
34
+ basis?: AgentBasis;
35
+ /** For a re-stamp: the upstream IDs or documents that had changed. */
36
+ restamped?: string[];
32
37
  }
33
38
 
34
39
  export interface ApproveOptions {
@@ -36,6 +41,8 @@ export interface ApproveOptions {
36
41
  replan?: string[];
37
42
  /** Elements this work's delta changes whose living text changed since its last approval; the approval accepts the current text. */
38
43
  rebase?: string[];
44
+ /** Approve for the user: a change whose plan review closed, or work a rethink left unchanged. */
45
+ agent?: AgentBasis;
39
46
  }
40
47
 
41
48
  /**
@@ -53,6 +60,9 @@ export async function approveArtifact(deps: ProjectDeps, ref: string, options: A
53
60
  if (item !== undefined && item.kind !== "feature" && !isPlanned(item.doc)) {
54
61
  throw new CruzeError("not-planned", `${path} has no test plan or tasks yet; plan it before approving`);
55
62
  }
63
+ const before = evaluateApproval(view, path);
64
+ if (options.agent === "plan-review") requirePlanReviewClosed(view.journal, item ?? notWork(path));
65
+ if (options.agent === "rethink") requireUnchangedSinceApproval(view.journal, item, path, before);
56
66
 
57
67
  const edits = new Map<string, string>();
58
68
  let text = view.snapshot.get(path) ?? "";
@@ -103,12 +113,23 @@ export async function approveArtifact(deps: ProjectDeps, ref: string, options: A
103
113
  upstream,
104
114
  approvedAt: deps.clock.now().toISOString(),
105
115
  by: (await deps.repository.userName()) ?? "unknown",
116
+ ...(options.agent === undefined ? {} : { basis: options.agent }),
106
117
  });
107
118
  for (const [changed, content] of edits) await deps.files.writeText(changed, content);
108
119
  await deps.files.writeText(approvalsPath(folder), serializeApprovals(approvals));
109
- await appendJournal(deps, folder, "approve", { artifact: path, hash, ...(replanned.length > 0 ? { replanned } : {}), ...(rebased.length > 0 ? { rebased } : {}) });
120
+ const restamped = options.agent === "rethink" ? before.changed : undefined;
121
+ await appendJournal(deps, folder, "approve", {
122
+ artifact: path,
123
+ hash,
124
+ ...(replanned.length > 0 ? { replanned } : {}),
125
+ ...(rebased.length > 0 ? { rebased } : {}),
126
+ ...(options.agent === undefined ? {} : { basis: options.agent }),
127
+ ...(restamped === undefined ? {} : { restamped }),
128
+ });
110
129
  const report: ApproveReport = { artifact: path, hash, upstream: Object.keys(upstream), stamped, replanned, rebased };
111
130
  if (bound !== undefined) report.bound = bound;
131
+ if (options.agent !== undefined) report.basis = options.agent;
132
+ if (restamped !== undefined) report.restamped = restamped;
112
133
  return report;
113
134
  }
114
135
 
@@ -133,6 +154,10 @@ function requireRebased(view: ProjectView, deltaIds: Set<string>, previous: Appr
133
154
  }
134
155
  }
135
156
 
157
+ function notWork(path: string): never {
158
+ throw new CruzeError("needs-user", `${path} is a project document, so the user approves it`);
159
+ }
160
+
136
161
  /** A change needs its feature approved; a feature or standalone change needs the architecture approved. */
137
162
  function requireUpstreamApproved(view: ProjectView, path: string): void {
138
163
  const item = view.items.find((i) => i.path === path);
@@ -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
+ }