@printpractical/cruze 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +11 -11
  2. package/dist/adapters/inbound/cli/argument_files.js +29 -0
  3. package/dist/adapters/inbound/cli/cli_context.js +1 -0
  4. package/dist/adapters/inbound/cli/command_options.js +20 -0
  5. package/dist/adapters/inbound/cli/commands/journal.js +3 -2
  6. package/dist/adapters/inbound/cli/commands/progress.js +7 -3
  7. package/dist/adapters/inbound/cli/commands/review.js +4 -3
  8. package/dist/adapters/inbound/cli/commands/status.js +3 -0
  9. package/dist/adapters/inbound/cli/commands.js +32 -20
  10. package/dist/adapters/inbound/cli/parse_command.js +8 -2
  11. package/dist/adapters/inbound/cli/run_cli.js +9 -5
  12. package/dist/adapters/outbound/node_project_files.js +4 -2
  13. package/dist/adapters/outbound/process_agent_runner.js +8 -3
  14. package/dist/app/ports/agent_runner.js +5 -6
  15. package/dist/app/use_cases/init_project.js +31 -3
  16. package/dist/app/use_cases/journal_events.js +12 -4
  17. package/dist/app/use_cases/project_status.js +8 -2
  18. package/dist/app/use_cases/record_progress.js +17 -0
  19. package/dist/app/use_cases/run_review.js +25 -11
  20. package/dist/domain/config.js +8 -8
  21. package/dist/domain/scaffold.js +15 -1
  22. package/dist/domain/status/work_status.js +1 -3
  23. package/package.json +1 -1
  24. package/skills/about/SKILL.md +1 -1
  25. package/skills/envision/SKILL.md +1 -1
  26. package/skills/envision/feature.md +1 -0
  27. package/skills/formats/reference/config.md +1 -1
  28. package/skills/formats/reference/durable-docs.md +1 -1
  29. package/skills/land/SKILL.md +1 -0
  30. package/skills/realign/notes/0.0.3.md +11 -0
  31. package/skills/realign/notes/0.0.4.md +27 -0
  32. package/skills/realign/notes.md +2 -0
  33. package/skills/rethink/levels.md +1 -1
  34. package/skills/roles/SKILL.md +1 -1
  35. package/src/adapters/inbound/cli/argument_files.ts +29 -0
  36. package/src/adapters/inbound/cli/cli_context.ts +1 -0
  37. package/src/adapters/inbound/cli/command_options.ts +20 -0
  38. package/src/adapters/inbound/cli/commands/journal.ts +3 -2
  39. package/src/adapters/inbound/cli/commands/progress.ts +7 -3
  40. package/src/adapters/inbound/cli/commands/review.ts +4 -3
  41. package/src/adapters/inbound/cli/commands/status.ts +3 -0
  42. package/src/adapters/inbound/cli/commands.ts +32 -20
  43. package/src/adapters/inbound/cli/parse_command.ts +8 -2
  44. package/src/adapters/inbound/cli/run_cli.ts +9 -5
  45. package/src/adapters/outbound/node_project_files.ts +4 -2
  46. package/src/adapters/outbound/process_agent_runner.ts +8 -3
  47. package/src/app/ports/agent_runner.ts +5 -6
  48. package/src/app/use_cases/init_project.ts +31 -3
  49. package/src/app/use_cases/journal_events.ts +12 -4
  50. package/src/app/use_cases/project_status.ts +8 -2
  51. package/src/app/use_cases/record_progress.ts +17 -0
  52. package/src/app/use_cases/run_review.ts +25 -11
  53. package/src/domain/config.ts +8 -8
  54. package/src/domain/scaffold.ts +15 -1
  55. package/src/domain/status/work_status.ts +1 -3
  56. package/templates/init/config.yaml +1 -6
  57. package/templates/init/review/claude.yaml +7 -0
  58. package/templates/init/review/other.yaml +7 -0
  59. package/templates/init/CLAUDE.md +0 -1
@@ -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 {
@@ -78,7 +79,8 @@ export class NodeProjectFiles implements ProjectFiles {
78
79
  this.assertInsideRoot(resolve(dirname(linkPath), target));
79
80
  await mkdir(dirname(linkPath), { recursive: true });
80
81
  await rm(linkPath, { recursive: true, force: true });
81
- await symlink(target, linkPath, "dir");
82
+ // No link type: on Windows, Node picks a file or directory link from what the target is.
83
+ await symlink(target, linkPath);
82
84
  }
83
85
 
84
86
  /** Resolves a project-relative path, refusing anything outside the root. */
@@ -91,7 +93,7 @@ export class NodeProjectFiles implements ProjectFiles {
91
93
  private assertInsideRoot(absolute: string): void {
92
94
  const fromRoot = relative(this.root, absolute);
93
95
  if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
94
- throw new Error(`Path escapes the project root: ${absolute}`);
96
+ throw new CruzeError("outside-project", `${absolute} is outside the project at ${this.root}`);
95
97
  }
96
98
  }
97
99
  }
@@ -11,15 +11,20 @@ export class ProcessAgentRunner implements AgentRunner {
11
11
 
12
12
  run(command: string[], prompt: string): Promise<AgentRun> {
13
13
  const [program, ...args] = command;
14
- if (program === undefined) return Promise.resolve({ exitCode: 127, output: "", error: "no command configured" });
14
+ if (program === undefined) return Promise.resolve({ kind: "missing" });
15
15
  return new Promise((resolve) => {
16
16
  const child = spawn(program, args, { cwd: this.cwd, stdio: ["pipe", "pipe", "pipe"] });
17
17
  let output = "";
18
18
  let error = "";
19
19
  child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
20
20
  child.stderr.on("data", (chunk: Buffer) => (error += chunk.toString("utf8")));
21
- child.on("error", (failure) => resolve({ exitCode: 127, output, error: `${error}${failure.message}` }));
22
- child.on("close", (code) => resolve({ exitCode: code ?? 1, output, error }));
21
+ // Spawning reports ENOENT when the program isn't on the PATH.
22
+ child.on("error", (failure: NodeJS.ErrnoException) =>
23
+ resolve(failure.code === "ENOENT" ? { kind: "missing" } : { kind: "finished", exitCode: 127, output, error: `${error}${failure.message}` }),
24
+ );
25
+ child.on("close", (code) => resolve({ kind: "finished", exitCode: code ?? 1, output, error }));
26
+ // A program that never started closes stdin; ignore the write error, since the spawn error reports it.
27
+ child.stdin.on("error", () => {});
23
28
  child.stdin.end(prompt);
24
29
  });
25
30
  }
@@ -1,11 +1,10 @@
1
1
  /** Starts a coding agent in a fresh context, non-interactively, and collects what it reports. */
2
2
  export interface AgentRunner {
3
- /** Runs `command` in the project root with `prompt` on standard input. */
3
+ /**
4
+ * Runs `command` in the project root with `prompt` on standard input. Reports `missing`,
5
+ * without running anything, when the command's program isn't installed.
6
+ */
4
7
  run(command: string[], prompt: string): Promise<AgentRun>;
5
8
  }
6
9
 
7
- export interface AgentRun {
8
- exitCode: number;
9
- output: string;
10
- error: string;
11
- }
10
+ export type AgentRun = { kind: "finished"; exitCode: number; output: string; error: string } | { kind: "missing" };
@@ -1,5 +1,7 @@
1
+ import { CruzeError } from "../../domain/cruze_error.ts";
2
+ import { agentSkillHome } from "../../domain/agent_skill_homes.ts";
1
3
  import { parseProjectName } from "../../domain/project_name.ts";
2
- import { INIT_SCAFFOLD, renderTemplate } from "../../domain/scaffold.ts";
4
+ import { CLAUDE_INSTRUCTIONS, INIT_AGENTS, INIT_SCAFFOLD, type InitAgent, renderTemplate, reviewSettingTemplate } from "../../domain/scaffold.ts";
3
5
  import type { Bundle } from "../ports/bundle.ts";
4
6
  import type { ProjectFiles } from "../ports/project_files.ts";
5
7
  import type { Prompter } from "../ports/prompter.ts";
@@ -10,6 +12,7 @@ export interface InitProjectRequest {
10
12
  name?: string;
11
13
  /** Offered as the default answer when asking for the name. */
12
14
  defaultName: string;
15
+ /** From `--agent`: `claude`, `other`, or agents to link skills for. Asked for when it names neither. */
13
16
  agents: string[];
14
17
  /**
15
18
  * How CI runs this version of Cruze through npx, such as `github:PrintPractical/cruze#v0.0.1`
@@ -20,6 +23,7 @@ export interface InitProjectRequest {
20
23
 
21
24
  export interface InitProjectReport {
22
25
  project: string;
26
+ agent: InitAgent;
23
27
  created: string[];
24
28
  /** Files that already existed and were left untouched. */
25
29
  skipped: string[];
@@ -36,11 +40,13 @@ export async function initProject(
36
40
  ): Promise<InitProjectReport> {
37
41
  const { files, bundle, prompter } = deps;
38
42
  const project = parseProjectName(request.name ?? (await prompter.ask("Project name", request.defaultName)));
43
+ const agent = await chooseAgent(files, prompter, request.agents);
39
44
  const values = {
40
45
  project_name: project,
41
46
  project_name_quoted: JSON.stringify(project),
42
47
  cruze_version: bundle.version,
43
48
  cruze_package: request.package ?? `@printpractical/cruze@${bundle.version}`,
49
+ review_setting: (await bundle.template(reviewSettingTemplate(agent))).trimEnd(),
44
50
  };
45
51
 
46
52
  const created: string[] = [];
@@ -53,7 +59,29 @@ export async function initProject(
53
59
  await files.writeText(entry.path, renderTemplate(await bundle.template(entry.template), values));
54
60
  created.push(entry.path);
55
61
  }
62
+ if (agent === "claude") {
63
+ if (await files.exists(CLAUDE_INSTRUCTIONS.path)) skipped.push(CLAUDE_INSTRUCTIONS.path);
64
+ else {
65
+ await files.link(CLAUDE_INSTRUCTIONS.path, CLAUDE_INSTRUCTIONS.target);
66
+ created.push(CLAUDE_INSTRUCTIONS.path);
67
+ }
68
+ }
56
69
 
57
- const skills = await installSkills({ files, bundle }, { agents: request.agents });
58
- return { project, created, skipped, skills };
70
+ const linkFor = new Set(request.agents.filter((a) => a !== "other"));
71
+ if (agent === "claude") linkFor.add("claude");
72
+ const skills = await installSkills({ files, bundle }, { agents: [...linkFor] });
73
+ return { project, agent, created, skipped, skills };
74
+ }
75
+
76
+ /** The agent named with `--agent`, or shown by the project's files, or else the answer to asking. */
77
+ async function chooseAgent(files: ProjectFiles, prompter: Prompter, requested: string[]): Promise<InitAgent> {
78
+ if (requested.includes("claude")) return "claude";
79
+ if (requested.includes("other")) return "other";
80
+ for (const marker of agentSkillHome("claude")?.markers ?? []) {
81
+ if (await files.exists(marker)) return "claude";
82
+ }
83
+ const answer = (await prompter.ask(`Coding agent (${INIT_AGENTS.join(" or ")})`, "claude")).trim().toLowerCase();
84
+ const agent = INIT_AGENTS.find((a) => a === answer);
85
+ if (agent === undefined) throw new CruzeError("unknown-agent", `Unknown agent: ${answer}. Answer ${INIT_AGENTS.join(" or ")}, or pass --agent.`);
86
+ return agent;
59
87
  }
@@ -1,5 +1,5 @@
1
1
  import { CruzeError } from "../../domain/cruze_error.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);
@@ -18,16 +18,19 @@ export interface ReviewRequest {
18
18
  base?: string;
19
19
  }
20
20
 
21
- export interface ReviewReport {
22
- role: Role;
23
- item?: string;
24
- command: string[];
25
- report: string;
26
- }
21
+ /**
22
+ * The role's report, or, when the configured agent isn't installed, the prompt for the calling
23
+ * agent to run in a helper of its own.
24
+ */
25
+ export type ReviewReport = { role: Role; item?: string; command?: string[] } & (
26
+ | { status: "reported"; report: string }
27
+ | { status: "run-in-helper"; instruction: string; prompt: string }
28
+ );
27
29
 
28
30
  /**
29
31
  * Runs a Cruze role in a fresh agent context: the role's prompt, what it reviews, and nothing
30
- * from the conversation that produced the work. Returns the role's report as written.
32
+ * from the conversation that produced the work. Returns the role's report as written, or the
33
+ * prompt to run in a helper when the configured agent isn't installed.
31
34
  */
32
35
  export async function runReview(deps: ProjectDeps & { runner: AgentRunner }, request: ReviewRequest): Promise<ReviewReport> {
33
36
  if (!(ROLES as readonly string[]).includes(request.role)) throw new CruzeError("unknown-role", `the roles are ${ROLES.join(", ")}`);
@@ -55,12 +58,23 @@ export async function runReview(deps: ProjectDeps & { runner: AgentRunner }, req
55
58
  context.map((line) => `- ${line}`).join("\n"),
56
59
  ].join("\n\n");
57
60
 
58
- const command = view.config?.config?.review.command ?? [];
61
+ const subject = { role, ...(request.item === undefined ? {} : { item: request.item }) };
62
+ const command = view.config?.config?.review.command;
63
+ if (command === undefined) return { ...subject, status: "run-in-helper", instruction: helperInstruction(undefined), prompt };
59
64
  const run = await deps.runner.run(command, prompt);
65
+ if (run.kind === "missing") return { ...subject, command, status: "run-in-helper", instruction: helperInstruction(command[0]), prompt };
60
66
  if (run.exitCode !== 0) throw new CruzeError("review-failed", `${command.join(" ")} exited with ${run.exitCode}: ${run.error.trim() || run.output.trim()}`);
61
- const report: ReviewReport = { role, command, report: run.output.trim() };
62
- if (request.item !== undefined) report.item = request.item;
63
- return report;
67
+ return { ...subject, command, status: "reported", report: run.output.trim() };
68
+ }
69
+
70
+ /** Why cruze review can't start the fresh context itself, and how the calling agent does instead. */
71
+ function helperInstruction(program: string | undefined): string {
72
+ return [
73
+ program === undefined ? "No review agent is configured under review.command." : `${program} is not installed, so cruze review can't start a fresh context.`,
74
+ "Start a helper agent with no conversation history, give it this prompt and nothing else, and take its report as the role's report.",
75
+ "If you can't start a helper, tell the user, and have them run the prompt in a new session.",
76
+ "To have cruze review start an agent itself, set review.command in .cruze/config.yaml.",
77
+ ].join(" ");
64
78
  }
65
79
 
66
80
  async function bundledRole(deps: ProjectDeps, role: Role): Promise<string> {
@@ -19,13 +19,13 @@ export interface CruzeConfig {
19
19
  layers: Layer[];
20
20
  /** Named project commands, such as `test: cargo test`, shown in AGENTS.md. */
21
21
  commands: Record<string, string>;
22
- /** How `cruze review` starts an agent in a fresh context: the program and its arguments. */
23
- review: { command: string[] };
22
+ /**
23
+ * How `cruze review` starts an agent in a fresh context: the program and its arguments.
24
+ * Without one, the calling agent runs the role's prompt in a helper of its own.
25
+ */
26
+ review: { command?: string[] };
24
27
  }
25
28
 
26
- /** Claude Code in print mode, allowed to read the project, its history and run `cruze`, but not to edit. */
27
- export const DEFAULT_REVIEW_COMMAND = ["claude", "-p", "--allowedTools", "Read Grep Glob Bash(git diff:*) Bash(git log:*) Bash(git show:*) Bash(cruze:*)"];
28
-
29
29
  export interface Layer {
30
30
  name: string;
31
31
  paths: string[];
@@ -95,8 +95,8 @@ export function parseConfig(text: string): ConfigResult {
95
95
  }
96
96
  }
97
97
  const review = asRecord(root["review"]) ?? {};
98
- const reviewCommand = review["command"] === undefined ? DEFAULT_REVIEW_COMMAND : stringList(review["command"], "review.command", problems);
99
- if (reviewCommand.length === 0) problems.push("review.command needs at least the program to run");
98
+ const reviewCommand = review["command"] === undefined ? undefined : stringList(review["command"], "review.command", problems);
99
+ if (reviewCommand?.length === 0) problems.push("review.command needs at least the program to run");
100
100
 
101
101
  const config: CruzeConfig = {
102
102
  version: 1,
@@ -109,7 +109,7 @@ export function parseConfig(text: string): ConfigResult {
109
109
  check: { maxLines: maxLines ?? 250, maxTypes: maxTypes ?? 5, exceptions },
110
110
  layers,
111
111
  commands,
112
- review: { command: reviewCommand.length === 0 ? DEFAULT_REVIEW_COMMAND : reviewCommand },
112
+ review: reviewCommand === undefined || reviewCommand.length === 0 ? {} : { command: reviewCommand },
113
113
  };
114
114
  return { config, problems };
115
115
  }
@@ -11,12 +11,26 @@ export const INIT_SCAFFOLD: readonly ScaffoldEntry[] = [
11
11
  { path: "README.md", template: "init/README.md" },
12
12
  { path: "CHANGELOG.md", template: "init/CHANGELOG.md" },
13
13
  { path: "AGENTS.md", template: "init/AGENTS.md" },
14
- { path: "CLAUDE.md", template: "init/CLAUDE.md" },
15
14
  { path: ".cruze/config.yaml", template: "init/config.yaml" },
16
15
  { path: ".github/workflows/ci.yml", template: "init/ci.yml" },
17
16
  { path: ".gitattributes", template: "init/gitattributes" },
18
17
  ];
19
18
 
19
+ /**
20
+ * The agents `cruze init` sets a project up for: Claude Code, which gets `CLAUDE.md`, linked skills
21
+ * and a review command, or any other agent, which reads `AGENTS.md` and `.agents/skills/`.
22
+ */
23
+ export const INIT_AGENTS = ["claude", "other"] as const;
24
+ export type InitAgent = (typeof INIT_AGENTS)[number];
25
+
26
+ /** Claude Code reads `CLAUDE.md`, so init links it to `AGENTS.md` rather than keeping a second copy. */
27
+ export const CLAUDE_INSTRUCTIONS = { path: "CLAUDE.md", target: "AGENTS.md" };
28
+
29
+ /** The template for the config's `review:` setting, which only Claude Code gets filled in. */
30
+ export function reviewSettingTemplate(agent: InitAgent): string {
31
+ return `init/review/${agent}.yaml`;
32
+ }
33
+
20
34
  /** Replaces every `{{key}}` with its value; an unknown key is a template bug. */
21
35
  export function renderTemplate(template: string, values: Record<string, string>): string {
22
36
  return template.replace(/\{\{([a-z_]+)\}\}/g, (placeholder, key: string) => {
@@ -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
  }
@@ -29,9 +29,4 @@ layers: []
29
29
  # walking-skeleton change fills them in; `cruze land` copies them into AGENTS.md.
30
30
  commands: {}
31
31
 
32
- # How `cruze review` starts an agent in a fresh context to run a role, with the
33
- # prompt on standard input. The default lets Claude Code read the project and run
34
- # git and cruze. The verifier runs the system, so add the project's build and run
35
- # commands, such as "Bash(cargo:*)". For Codex, use ["codex", "exec", "-"].
36
- review:
37
- command: ["claude", "-p", "--allowedTools", "Read Grep Glob Bash(git diff:*) Bash(git log:*) Bash(git show:*) Bash(cruze:*)"]
32
+ {{review_setting}}
@@ -0,0 +1,7 @@
1
+ # How `cruze review` starts an agent in a fresh context to run a role, with the
2
+ # prompt on standard input. This lets Claude Code read the project and run git
3
+ # and cruze. The verifier runs the system, so add the project's build and run
4
+ # commands, such as "Bash(cargo:*)". Without review.command, your agent runs each
5
+ # role in a helper of its own.
6
+ review:
7
+ command: ["claude", "-p", "--allowedTools", "Read Grep Glob Bash(git diff:*) Bash(git log:*) Bash(git show:*) Bash(cruze:*)"]
@@ -0,0 +1,7 @@
1
+ # How `cruze review` starts an agent in a fresh context to run a role, with the
2
+ # prompt on standard input. Without it, your agent runs each role in a helper
3
+ # of its own. To have cruze review start one instead, set the command, such as
4
+ # ["codex", "exec", "-"] for Codex or ["opencode", "run", "--agent", "plan"] for
5
+ # OpenCode.
6
+ # review:
7
+ # command: ["codex", "exec", "-"]
@@ -1 +0,0 @@
1
- @AGENTS.md