@printpractical/cruze 0.0.8 → 0.0.9

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 (55) hide show
  1. package/README.md +4 -4
  2. package/dist/adapters/inbound/cli/cli_context.js +2 -0
  3. package/dist/adapters/inbound/cli/commands/code.js +2 -1
  4. package/dist/adapters/inbound/cli/commands/show.js +6 -1
  5. package/dist/adapters/outbound/git_repository.js +40 -7
  6. package/dist/adapters/outbound/project_root.js +13 -0
  7. package/dist/app/use_cases/project_status.js +4 -1
  8. package/dist/app/use_cases/show_scope.js +3 -1
  9. package/dist/domain/check/budget_text.js +43 -6
  10. package/dist/domain/check/check_source.js +2 -2
  11. package/dist/domain/config.js +12 -0
  12. package/dist/domain/edits/agents_file.js +6 -1
  13. package/dist/domain/journal.js +13 -2
  14. package/dist/domain/project/scope_view.js +20 -4
  15. package/dist/domain/status/next_step.js +25 -2
  16. package/dist/domain/status/verification.js +12 -0
  17. package/dist/domain/validation/plan_rules.js +1 -1
  18. package/dist/main.js +8 -4
  19. package/package.json +1 -1
  20. package/skills/architect/SKILL.md +4 -1
  21. package/skills/build/SKILL.md +2 -1
  22. package/skills/formats/reference/architecture.md +1 -1
  23. package/skills/formats/reference/config.md +9 -4
  24. package/skills/hexagonal-design/SKILL.md +2 -2
  25. package/skills/plan/SKILL.md +3 -3
  26. package/skills/plan/tasks.md +1 -1
  27. package/skills/realign/SKILL.md +1 -1
  28. package/skills/realign/notes/0.0.8.md +1 -1
  29. package/skills/realign/notes/0.0.9.md +59 -0
  30. package/skills/realign/notes.md +1 -0
  31. package/skills/rethink/SKILL.md +2 -2
  32. package/skills/rethink/levels.md +2 -2
  33. package/skills/roles/SKILL.md +1 -1
  34. package/skills/roles/code-reviewer.md +3 -2
  35. package/skills/roles/design-reviewer.md +13 -1
  36. package/skills/roles/verifier.md +2 -0
  37. package/skills/verify/SKILL.md +3 -3
  38. package/src/adapters/inbound/cli/cli_context.ts +2 -0
  39. package/src/adapters/inbound/cli/commands/code.ts +2 -1
  40. package/src/adapters/inbound/cli/commands/show.ts +6 -1
  41. package/src/adapters/outbound/git_repository.ts +40 -7
  42. package/src/adapters/outbound/project_root.ts +13 -0
  43. package/src/app/use_cases/project_status.ts +4 -1
  44. package/src/app/use_cases/show_scope.ts +3 -1
  45. package/src/domain/check/budget_text.ts +43 -6
  46. package/src/domain/check/check_source.ts +2 -2
  47. package/src/domain/config.ts +12 -0
  48. package/src/domain/edits/agents_file.ts +6 -1
  49. package/src/domain/journal.ts +13 -2
  50. package/src/domain/project/scope_view.ts +20 -4
  51. package/src/domain/status/next_step.ts +25 -2
  52. package/src/domain/status/verification.ts +12 -0
  53. package/src/domain/validation/plan_rules.ts +1 -1
  54. package/src/main.ts +8 -4
  55. package/templates/init/config.yaml +5 -4
@@ -19,9 +19,14 @@ export function renderAgentsFile(text: string, view: ProjectView): string {
19
19
  return next;
20
20
  }
21
21
 
22
+ /**
23
+ * The modules whose code exists: those built, and those a land built before a rethink set them
24
+ * back to `planned` for a change to rebuild, since their directories are still there.
25
+ */
22
26
  function builtModules(view: ProjectView): string[] {
27
+ const everBuilt = new Set(view.journal.filter((e) => e.event === "land").flatMap((e) => (Array.isArray(e["built"]) ? (e["built"] as unknown[]).map(String) : [])));
23
28
  return [...view.living.elements.values()]
24
- .filter(({ element, doc }) => element.kind === "MOD" && statusOf(doc, element) === "built")
29
+ .filter(({ element, doc }) => element.kind === "MOD" && (statusOf(doc, element) === "built" || everBuilt.has(element.id)))
25
30
  .map(({ element, doc }) => {
26
31
  const facts = factsOf(doc, element);
27
32
  const paths = (facts.get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => `\`${m[1] ?? ""}\``));
@@ -23,6 +23,7 @@ const OPTIONAL_FIELDS: Record<string, string[]> = {
23
23
  rethink: ["missed_by"],
24
24
  review: ["nits", "checked_by"],
25
25
  disposition: ["decided"],
26
+ verification: ["back_to"],
26
27
  };
27
28
 
28
29
  /** The workflow steps and checks that find or miss a problem, so a retro can count them. */
@@ -35,11 +36,21 @@ export const FIELD_VALUES: Record<string, string[]> = {
35
36
  decided: ["agent", "user"],
36
37
  review: ["design", "plan", "code", "realign", "improve"],
37
38
  result: ["accepted", "sent-back"],
39
+ back_to: ["build", "rethink"],
38
40
  checked_by: ["agent", "reviewer"],
39
41
  found_by: STEPS,
40
42
  missed_by: STEPS,
41
43
  };
42
44
 
45
+ /** A defect rethink must say which step missed it, and a sent-back verification where the change goes back to. */
46
+ function conditionallyRequired(event: string, fields: Record<string, string>): string[] {
47
+ if (event === "rethink" && fields["kind"] === "defect") return ["missed_by"];
48
+ if (event === "verification" && fields["result"] === "sent-back") return ["back_to"];
49
+ return [];
50
+ }
51
+
52
+ const CONDITION_NOTES: Record<string, string> = { missed_by: " for a defect", back_to: " when the change is sent back" };
53
+
43
54
  /** Fields recorded as numbers rather than text. */
44
55
  const COUNT_FIELDS = ["round", "blockers", "concerns", "nits"];
45
56
 
@@ -71,8 +82,8 @@ export function serializeEntry(entry: JournalEntry): string {
71
82
  export function recordedEventProblems(event: string, fields: Record<string, string>): string[] {
72
83
  const required = RECORDED_EVENTS[event];
73
84
  if (required === undefined) return [`unknown event "${event}"; recordable events: ${Object.keys(RECORDED_EVENTS).join(", ")}`];
74
- const needed = event === "rethink" && fields["kind"] === "defect" ? [...required, "missed_by"] : required;
75
- const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${field === "missed_by" ? " for a defect" : ""}`);
85
+ const needed = [...required, ...conditionallyRequired(event, fields)];
86
+ const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${CONDITION_NOTES[field] ?? ""}`);
76
87
  const known = [...required, ...(OPTIONAL_FIELDS[event] ?? [])];
77
88
  for (const field of known) {
78
89
  const value = fields[field];
@@ -36,8 +36,10 @@ export interface ScopeView {
36
36
  scenarios: ShownElement[];
37
37
  /** Elements the shown bodies cite but don't include, with where to read them. */
38
38
  cited: CitedElement[];
39
- /** The settled decisions of the feature, then of the change. */
39
+ /** The settled decisions of the feature, or of a standalone change. */
40
40
  decisions: string[];
41
+ /** A feature's change's own settled decisions, numbered apart from the feature's. */
42
+ changeDecisions: string[];
41
43
  /** Scope IDs defined nowhere: the delta, the living docs or the archive. */
42
44
  missing: string[];
43
45
  }
@@ -66,7 +68,7 @@ export function scopeView(view: ProjectView, item: WorkItem): ScopeView {
66
68
  return [shown];
67
69
  });
68
70
  const elements = show(wanted.elements);
69
- const scenarios = show(wanted.scenarios);
71
+ const scenarios = withoutRepeatedScenarios(show(wanted.scenarios));
70
72
  const shownIds = new Set([...elements, ...scenarios].map((e) => e.id));
71
73
  const cited = [...new Set([...elements, ...scenarios].flatMap((e) => findIds(e.body.join("\n"))))]
72
74
  .filter((id) => !shownIds.has(id))
@@ -74,12 +76,26 @@ export function scopeView(view: ProjectView, item: WorkItem): ScopeView {
74
76
  const found = locate(id);
75
77
  return found === undefined ? [] : [{ id, title: found.element.title, source: `${found.path}:${found.element.start + 1}` }];
76
78
  });
77
- const decisions = feature === undefined ? decisionLines(item.doc) : [...decisionLines(feature.doc), ...decisionLines(item.doc)];
78
- const result: ScopeView = { ref: item.ref, kind: item.kind, elements, scenarios, cited, decisions, missing };
79
+ const decisions = decisionLines((feature ?? item).doc);
80
+ const changeDecisions = feature === undefined ? [] : decisionLines(item.doc);
81
+ const result: ScopeView = { ref: item.ref, kind: item.kind, elements, scenarios, cited, decisions, changeDecisions, missing };
79
82
  if (feature !== undefined) result.feature = feature.ref;
80
83
  return result;
81
84
  }
82
85
 
86
+ /**
87
+ * A requirement shown beside its own scenarios keeps only its own text: its body would repeat
88
+ * each of them, which made up a third of one change's output.
89
+ */
90
+ function withoutRepeatedScenarios(shown: ShownElement[]): ShownElement[] {
91
+ const ids = new Set(shown.map((e) => e.id));
92
+ return shown.map((element) => {
93
+ if (!element.id.startsWith("REQ-")) return element;
94
+ const first = element.body.findIndex((line, i) => i > 0 && /^#{2,6} /.test(line) && findIds(line).some((id) => ids.has(id)));
95
+ return first === -1 ? element : { ...element, body: element.body.slice(0, first) };
96
+ });
97
+ }
98
+
83
99
  /** A change shows what its scope builds, removes and delivers. */
84
100
  function changeIds(change: WorkItem): { elements: string[]; scenarios: string[] } {
85
101
  const scope = readScope(change.doc);
@@ -7,7 +7,7 @@ import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.ts";
7
7
  import { changesOf, featureOf, type WorkItem } from "../project/work_items.ts";
8
8
  import { compareVersions } from "../versions/cruze_version.ts";
9
9
  import { FIRST_RELEASE } from "../versions/rule_versions.ts";
10
- import { isVerified } from "./verification.ts";
10
+ import { awaitsRethink, isVerified } from "./verification.ts";
11
11
  import { activeChange } from "./work_status.ts";
12
12
 
13
13
  /** The Cruze step to run next, computed from the project's state and the current branch. */
@@ -31,7 +31,7 @@ export function nextSteps(view: ProjectView, branch: string | null): NextReport
31
31
  const project = projectStep(view);
32
32
  if (project !== null) return { next: project, also: [] };
33
33
  const active = activeChange(view, branch);
34
- const inFlight = view.items.filter((item) => !item.archived && item.kind !== "change").flatMap((item) => itemSteps(view, item, branch));
34
+ const inFlight = inRoadmapOrder(view, view.items.filter((item) => !item.archived && item.kind !== "change")).flatMap((item) => itemSteps(view, item, branch));
35
35
  const roadmap = roadmapStep(view);
36
36
  const realign = realignStep(view);
37
37
  const candidates = [
@@ -45,6 +45,28 @@ export function nextSteps(view: ProjectView, branch: string | null): NextReport
45
45
  return { next: next ?? { step: "roadmap", reason: "every item on the roadmap has landed; close the release and plan the next" }, also };
46
46
  }
47
47
 
48
+ /**
49
+ * Work in flight in the roadmap's order, with items whose roadmap blockers haven't all landed
50
+ * last: a feature designed early still waits for the prefactors the user put before it.
51
+ */
52
+ function inRoadmapOrder(view: ProjectView, items: WorkItem[]): WorkItem[] {
53
+ const text = view.snapshot.get(PATHS.roadmap);
54
+ if (text === undefined) return items;
55
+ const rows = readRoadmapStatus(text);
56
+ const state = new Map(rows.map((row) => [row.slug, row.state]));
57
+ const slugOf = new Map(rows.map((row) => [row.id, row.slug]));
58
+ const roadmap = readRoadmapItems(text);
59
+ const rank = (item: WorkItem): [number, number] => {
60
+ const at = roadmap.findIndex((r) => r.slug === slugOf.get(item.ref));
61
+ const blocked = at !== -1 && (roadmap[at]?.blockedBy ?? []).some((b) => state.get(b) !== "landed");
62
+ return [blocked ? 1 : 0, at === -1 ? roadmap.length : at];
63
+ };
64
+ return items
65
+ .map((item, i) => ({ item, key: [...rank(item), i] }))
66
+ .sort((a, b) => (a.key[0] ?? 0) - (b.key[0] ?? 0) || (a.key[1] ?? 0) - (b.key[1] ?? 0) || (a.key[2] ?? 0) - (b.key[2] ?? 0))
67
+ .map(({ item }) => item);
68
+ }
69
+
48
70
  /** Realign comes when nothing else is due: the installed skills expect more than the code meets. */
49
71
  function realignStep(view: ProjectView): NextStep | null {
50
72
  const config = view.config?.config;
@@ -117,6 +139,7 @@ function changeStep(view: ProjectView, change: WorkItem, branch: string | null):
117
139
  if (!isPlanned(change.doc)) return { step: "plan", target: change.ref, reason: "the change is designed but has no test plan or tasks yet" };
118
140
  if (state.state === "unapproved") return { step: "plan", target: change.ref, reason: "review the plan and approve it on the branch that will build it" };
119
141
  if (state.state !== "approved") return { step: "rethink", target: change.ref, reason: `the change or something it cites changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` };
142
+ if (awaitsRethink(view.journal, change, feature)) return { step: "rethink", target: feature?.ref ?? change.ref, reason: `verify sent ${change.ref} back for a design change` };
120
143
  if (bound !== undefined && bound !== branch) return { step: "switch-branch", target: bound, reason: `${change.ref} is built on branch ${bound}` };
121
144
  const progress = readProgress(change.doc);
122
145
  if ([...progress.tasks.values()].some((commit) => commit === null)) return { step: "build", target: change.ref, reason: "the change has open tasks" };
@@ -15,3 +15,15 @@ export function isVerified(journal: JournalEntry[], change: WorkItem): boolean {
15
15
  const verifiedHash = approvals.filter((e) => e.at < verification.at).at(-1)?.["hash"];
16
16
  return approvals.filter((e) => e.at > verification.at).every((e) => e["hash"] === verifiedHash);
17
17
  }
18
+
19
+ /**
20
+ * Whether verify sent the change back for a rethink that hasn't been recorded yet. Tasks the
21
+ * agent reopens at verify would otherwise make the change look ready to build again.
22
+ */
23
+ export function awaitsRethink(journal: JournalEntry[], change: WorkItem, feature: WorkItem | undefined): boolean {
24
+ const verification = journal.filter((e) => e.event === "verification" && e["item"] === change.ref).sort((a, b) => a.at.localeCompare(b.at)).at(-1);
25
+ if (verification?.["result"] !== "sent-back" || verification["back_to"] !== "rethink") return false;
26
+ // An architecture-level rethink records no item, and counts too.
27
+ const refs = new Set([change.ref, feature?.ref, undefined]);
28
+ return !journal.some((e) => e.event === "rethink" && e.at > verification.at && refs.has(e["item"] as string | undefined));
29
+ }
@@ -46,7 +46,7 @@ export function changePlanProblems(
46
46
  if (kindOf(id) === "MOD") {
47
47
  const modulePaths = modulePathsOf(view, delta.doc, delta.delta, id);
48
48
  if (!taskPaths.some((p) => modulePaths.some((m) => p.startsWith(m)))) {
49
- problems.push(error(path, scope.line, "unproved-scope", `${id} is in scope but no task writes inside ${modulePaths.join(" or ") || "its path"}`));
49
+ problems.push(error(path, scope.line, "unproved-scope", `${id} is in scope but no task writes inside ${modulePaths.join(" or ") || "its path"}; a module that owns other files, such as a build script, lists each under its Path`));
50
50
  }
51
51
  } else if (kindOf(id) === "REQ") {
52
52
  const scenarios = delta.delta.entries.find((e) => e.element.id === id)?.scenarios.map((s) => s.id) ?? [];
package/src/main.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // Composition root: the only place that constructs adapters.
3
- import { basename } from "node:path";
3
+ import { basename, relative } from "node:path";
4
4
  import { runCli } from "./adapters/inbound/cli/run_cli.ts";
5
5
  import { GitRepository } from "./adapters/outbound/git_repository.ts";
6
6
  import { NodeProjectFiles } from "./adapters/outbound/node_project_files.ts";
7
7
  import { PackageBundle } from "./adapters/outbound/package_bundle.ts";
8
+ import { findProjectRoot } from "./adapters/outbound/project_root.ts";
8
9
  import { ProcessAgentRunner } from "./adapters/outbound/process_agent_runner.ts";
9
10
  import { SystemClock } from "./adapters/outbound/system_clock.ts";
10
11
  import { DefaultAnswerPrompter, TerminalPrompter } from "./adapters/outbound/terminal_prompter.ts";
@@ -16,18 +17,21 @@ process.stdout.on("error", (error: NodeJS.ErrnoException) => {
16
17
  });
17
18
 
18
19
  const cwd = process.cwd();
20
+ // Every command but init works on the project that holds the working directory, so it runs from any subdirectory.
21
+ const root = process.argv[2] === "init" ? cwd : (findProjectRoot(cwd) ?? cwd);
19
22
  const exitCode = await runCli(
20
23
  process.argv.slice(2),
21
24
  {
22
- files: new NodeProjectFiles(cwd),
25
+ files: new NodeProjectFiles(root),
23
26
  bundle: await PackageBundle.locate(),
24
27
  clock: new SystemClock(),
25
- repository: new GitRepository(cwd),
28
+ repository: new GitRepository(root),
26
29
  interactivePrompter: new TerminalPrompter(),
27
30
  unattendedPrompter: new DefaultAnswerPrompter(),
28
- runner: new ProcessAgentRunner(cwd),
31
+ runner: new ProcessAgentRunner(root),
29
32
  interactive: process.stdin.isTTY === true,
30
33
  directoryName: basename(cwd),
34
+ workingDirectory: relative(root, cwd).split("\\").join("/"),
31
35
  },
32
36
  {
33
37
  stdout: (text) => process.stdout.write(`${text}\n`),
@@ -15,11 +15,12 @@ tracker: markdown
15
15
  source: []
16
16
  tests: []
17
17
 
18
- # Budgets enforced by `cruze check`. List a file under exceptions, with a
19
- # reason, to allow it past a budget.
18
+ # Budgets enforced by `cruze check`: a backstop against a file that has
19
+ # outgrown one responsibility, not a size to design to. List a file under
20
+ # exceptions, with a reason, to allow it past a budget.
20
21
  check:
21
- max_lines: 250
22
- max_types: 5
22
+ max_lines: 600
23
+ max_types: 12
23
24
  exceptions: []
24
25
 
25
26
  # Layer rules, written by project-scope architecture design from the module map.